From ae9bf7f40bdfe98b23fcf4e4981143bcf9355e07 Mon Sep 17 00:00:00 2001 From: "mj.lee" Date: Fri, 23 Oct 2026 00:47:36 +0900 Subject: [PATCH 01/10] fix(port): refresh translations for upstream eb4db2b Remove the obsolete Make Bot UI patch hunk and Poteto Mode name rule because upstream now uses directory slugs. Refresh rules.sed for current runner defaults, reviewer fallbacks, and shipping verdicts. Translate the three new skill commands, including description triggers, and replace the help paragraph for another harness with omp installation guidance. Remove six stale-exempt.tsv entries for refreshed rules. Exempt the two Swarm sentences that upstream deleted, retaining their translations if those sentences return. Update both README skill counts to 59 total and 56 generated skills for this build. --- README.md | 2 +- omp-port/patches/zzz-make-bot-ui-omp.patch | 6 ------ omp-port/rules.sed | 19 ++++++++++--------- omp-port/stale-exempt.tsv | 8 ++------ plugins/pstack/README.md | 6 +++--- 5 files changed, 16 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e016fcd..ffd614e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ An omp marketplace carrying one plugin. `pstack` is Lauren Tan's engineering methodology for Cursor, ported to oh-my-pi and made -model and provider agnostic, 56 skills with 23 playbooks and 24 principle leaves. +model and provider agnostic, 59 skills with 23 playbooks and 24 principle leaves. Wide parallel runs are a `swarm` dispatch, not a separate plugin. ``` diff --git a/omp-port/patches/zzz-make-bot-ui-omp.patch b/omp-port/patches/zzz-make-bot-ui-omp.patch index c1551a2..fb627fc 100644 --- a/omp-port/patches/zzz-make-bot-ui-omp.patch +++ b/omp-port/patches/zzz-make-bot-ui-omp.patch @@ -2,12 +2,6 @@ diff --git a/skills/make-bot-ui/SKILL.md b/skills/make-bot-ui/SKILL.md index 285df75..1c71684 100644 --- a/skills/make-bot-ui/SKILL.md +++ b/skills/make-bot-ui/SKILL.md -@@ -1,4 +1,4 @@ - --- --name: Make Bot UI -+name: make-bot-ui - description: >- - Use when building a custom UI (page, dashboard, buttons) that should wake a @@ -9,4 +9,11 @@ disable-model-invocation: true # How to make a bot UI diff --git a/omp-port/rules.sed b/omp-port/rules.sed index cb3ed36..abc9e17 100644 --- a/omp-port/rules.sed +++ b/omp-port/rules.sed @@ -77,13 +77,13 @@ s#^\| Tooling \| `reflect tooling` \| `[^`]+` \| `references/tooling-reviewer\.m s#^\| Divergent \| `reflect judgment, divergent, synthesizer` \| `[^`]+` \| `references/divergent-reviewer\.md` \|$#| Divergent | discovered divergent agent | `references/divergent-reviewer.md` |# s#The synthesizer returns a structured Accepted / Rejected / Backlog list\.#Pass this explicit `outputSchema` when the live task schema exposes it:\n\n```json\n{"type":"object","required":["Accepted","Rejected","Backlog"],"properties":{"Accepted":{"type":"array","items":{"type":"object","required":["Problem","Proposal","Routing"],"properties":{"Problem":{"type":"string"},"Proposal":{"type":"string"},"Routing":{"type":"string"}}}},"Rejected":{"type":"array","items":{"type":"object","required":["Principle","Reason"],"properties":{"Principle":{"type":"string"},"Reason":{"type":"string"}}}},"Backlog":{"type":"array","items":{"type":"object","required":["Pattern","Hit","Mechanism"],"properties":{"Pattern":{"type":"string"},"Hit":{"type":"string"},"Mechanism":{"type":"string"}}}}}}\n```# # These model-map sentences must match raw upstream before slug and path token rewrites. -s#^Take the runners from the `architect runners` line in the `pstack-models\.mdc` rule, in place of the `arena runners` line\. If the rule or that line is missing, use `claude-opus-[^`]+`, `gpt-[^`]+`, `grok-[^`]+`\. Alias and rejected entries follow the runner rules in the \*\*arena\*\* skill's Phase A\.$#Use the same exact discovered runner agents as Arena, with the architect role in each brief. Each agent's frontmatter or `task.agentModelOverrides[]` selects its model; the task item has no `model` field.# -s#^3\. Pick the runners\. Use the `arena runners` line in `~/\.cursor/rules/pstack-models\.mdc`\. If the rule or that line is missing, default to one each on `claude-opus-[^`]+`, `gpt-[^`]+`, `grok-[^`]+`\. An `auto` or `inherit-parent` entry in this line or the cross-judge line means the parent model, so omit `model` for it\. If the Task tool rejects a configured entry, run that seat on its family's default and say so\. Families go by prefix: `claude-\*`, `gpt-\*`, and `grok-\*`\. With no family match, use `claude-opus-[^`]+`\. If it rejects a default, use the closest valid slug of the same family from its error message\.#3. Pick the runners. Resolve exact discovered agent names and inspect each agent's frontmatter or `task.agentModelOverrides[]`. Prefer distinct configured model families when the roster supplies them; otherwise keep the independent contexts and report weaker model diversity. A model override cannot create an agent.# -s#After all Phase B candidates complete, choose one model from the `arena cross-judge pool` line in `~/.cursor/rules/pstack-models\.mdc`\. If the rule or that line is missing, choose from `claude-opus-[^`]+`, `gpt-[^`]+`, `grok-[^`]+`\. Prefer a different model family from the parent's\.#After all Phase B candidates complete, choose an exact discovered judge agent whose configured model differs from the parent's when the roster provides one.# +s#^Take the runners from the `architect runners` line in the `pstack-models\.mdc` rule, in place of the `arena runners` line\. If the rule or that line is missing, use `claude-opus-[^`]+` and `grok-[^`]+`\. Alias and rejected entries follow the runner rules in the \*\*arena\*\* skill's Phase A\.$#Use the same exact discovered runner agents as Arena, with the architect role in each brief. Each agent's frontmatter or `task.agentModelOverrides[]` selects its model; the task item has no `model` field.# +s#^3\. Pick the runners\. Use the `arena runners` line in `~/\.cursor/rules/pstack-models\.mdc`\. If the rule or that line is missing, default to one each on `claude-opus-[^`]+` and `grok-[^`]+`\. An `auto` or `inherit-parent` entry in this line or the cross-judge line means the parent model, so omit `model` for it\. If the Task tool rejects a configured entry, run that seat on its family's default and say so\. Families go by prefix: `claude-\*`, and `grok-\*` with `cursor-grok-\*` and `composer-\*`\. A refused `composer-\*` entry runs on `grok-[^`]+`\. With no family match, use `claude-opus-[^`]+`\. If it rejects a default, use the closest valid slug of the same family from its error message\.#3. Pick the runners. Resolve exact discovered agent names and inspect each agent's frontmatter or `task.agentModelOverrides[]`. Prefer distinct configured model families when the roster supplies them; otherwise keep the independent contexts and report weaker model diversity. A model override cannot create an agent.# +s#After all Phase B candidates complete, choose one model from the `arena cross-judge pool` line in `~/.cursor/rules/pstack-models\.mdc`\. If the rule or that line is missing, choose from `claude-opus-[^`]+` and `grok-[^`]+`\. Prefer a different model family from the parent's\.#After all Phase B candidates complete, choose an exact discovered judge agent whose configured model differs from the parent's when the roster provides one.# # The judge is one task item, with posture in its brief rather than a task field. s#After all Phase B candidates complete, choose an exact discovered judge agent whose configured model differs from the parent's when the roster provides one\. Spawn one readonly judge subagent on that model\.#After all Phase B candidates complete, choose an exact discovered judge agent. Start the judge in one `task` call with one item in `tasks[]`, the required shared `context`, and that exact agent name. Its frontmatter or `task.agentModelOverrides[]` selects the model; the task item has no `model` field. The brief carries read-only posture, grants only the tools that discovered agent actually has, forbids writes, and has no task `readonly` field. Prefer a different configured model family when the operator's model policy and roster supply one; otherwise keep the exact agent and report weaker model diversity.# s#^- `model`: the configured `interrogate reviewers` entry, or the table default with no configured line\. For an `auto` or `inherit-parent` entry, omit `model` so that reviewer runs on the parent model\.$#- `agent`: an exact discovered reviewer agent name. Its frontmatter or `task.agentModelOverrides[]` selects the model; the task item has no `model` field# -s#If the Task tool rejects a configured entry, run that reviewer on the table default of its family and say so\. Families go by prefix: `claude-\*`, `gpt-\*`, and `grok-\*`\. With no family match, use Reviewer A's default\. If it rejects a table default, check the valid slugs in the Task tool's error message, pick the closest equivalent \(prefer the highest-reasoning tier of the same family\), spawn with it, and open a separate PR to update the default table\. Do not block the review on the slug issue\. Never treat an alias entry as a rejected slug or apply either fallback to it\.#If the requested exact reviewer is absent, use another discovered reviewer with the same brief and report the missing model diversity. A model override cannot create an agent.# +s#If the Task tool rejects a configured entry, run that reviewer on its family's table default and say so\. Families go by prefix: `claude-\*`, and `grok-\*` with `cursor-grok-\*` and `composer-\*`\. A refused `composer-\*` entry runs on Reviewer B's default\. With no family match, use Reviewer A's default\. If it rejects a table default, spawn with the closest equivalent from the valid slugs in its error message \(prefer the same family and reasoning tier\), and open a separate PR to update the default table\. Do not block the review on the slug issue\. Never treat an alias entry as a rejected slug or apply either fallback to it\.#If the requested exact reviewer is absent, use another discovered reviewer with the same brief and report the missing model diversity. A model override cannot create an agent.# s#^4\. Pick the worker model from the `swarm workers` line in `~/\.cursor/rules/pstack-models\.mdc`\. If the rule or that line is missing, use `grok-[^`]+`\. For `auto` or `inherit-parent`, omit `model` so the workers run on the parent model\. If the Task tool rejects a slug, use the default and say so\. If it rejects the default, use the closest valid slug of the same family from its error message\. For a model race, name each arm's model up front\.#4. Pick exact discovered agent names. Inspect each agent's frontmatter or `task.agentModelOverrides[]` for its model. A model race needs one loaded agent file and one exact override entry per arm; overrides cannot create agents.# # The Cursor wire fields, as tokens rather than as sentences. The sentence rules above carry the # meaning; this net is what keeps a Cursor field out of the tree when upstream rewords the @@ -163,8 +163,6 @@ s#Substituting `generalPurpose` skips that read and drifts\.#Substituting `task` # so an unpinned read of skill://poteto-mode or a spawned poteto-agent still finds it. s|^## Non-negotiables$|## Non-negotiables\n\n**Read `skill://omp-mechanics` and `skill://pstack-omp` right after this file.** `pstack-omp` is the sole live task, vibe, eval, and runtime contract. `omp-mechanics` keeps only pstack-specific OMP deltas.| s#Reads the `poteto-mode` skill's `SKILL.md` in full before any work, including its inline Principles index\.#Reads the `poteto-mode` skill's `SKILL.md` in full before any work, including its inline Principles index, then `skill://omp-mechanics`.# -# Cursor derives a mode skill's registry name from a display title; omp uses the slug. -s#name: Poteto Mode#name: poteto-mode# ## 4. Cloud agents. Cursor runs them on its own VMs; omp runs isolated subagents on this machine. @@ -369,12 +367,14 @@ s#Subagents inherit it\. Multiple `Task` calls on the same branch each get their # forgot: upstream added eighteen skills after it was written and every one of them shipped a bare # /name the gate could not accept. Adding a skill upstream now means adding its name to the # alternation, once. -s#`/(teach|recall|reflect|tdd|swarm|arena|interrogate|correct|setup-pstack|automate-me|figure-it-out|show-me-your-work|benchmark-checklist|blast-radius|ketchup|bro|make-bot-ui|poteto-help|no-comments|unslop|create-verification-skill|maintain-verification-skill|architect|why|how|technical-writing|sharpen|in-your-own-words|typescript-best-practices|poteto-mode|principle-[a-z-]+)`#`/skill:\1`#g +s#`/(teach|recall|reflect|tdd|swarm|arena|interrogate|correct|setup-pstack|automate-me|figure-it-out|show-me-your-work|benchmark-checklist|blast-radius|ketchup|bro|make-bot-ui|poteto-help|no-comments|unslop|create-verification-skill|maintain-verification-skill|create-formal-verification-skill|maintain-formal-verification-skill|statechart|architect|why|how|technical-writing|sharpen|in-your-own-words|typescript-best-practices|poteto-mode|principle-[a-z-]+)`#`/skill:\1`#g # One verdict vocabulary. pstack-omp and swarm both name PASS/ISSUES/BLOCKED; shipping named a # different three, and its landing rule tested for a different set, so a verifier told by one file # and checked by another could not satisfy both. -s#Each returns `PASS`, `PASS\+NOTES` or `FAIL` and posts that verdict on its own PR\.#Each returns `PASS`, `ISSUES` or `BLOCKED`, the three verdicts `skill://pstack-omp` and **swarm** use, and posts that verdict on its own PR. Notes ride on a `PASS` rather than forming a fourth verdict, so a passing run with observations is still a pass.# -s#stop at the first one without a passing verdict, where both `PASS` and `PASS\+NOTES` pass\.#stop at the first one whose verdict is not `PASS`.# +s#posts `PASS`, `PASS\+NOTES`, or `FAIL` on that PR\.#posts `PASS`, `ISSUES`, or `BLOCKED`, the three verdicts `skill://pstack-omp` and **swarm** use, on that PR. Notes ride on a `PASS` rather than forming a fourth verdict, so a passing run with observations is still a pass.# +s#stop at the first one without `PASS` or `PASS\+NOTES`\.#stop at the first one whose verdict is not `PASS`.# +# Cursor's help names another harness's install commands and an adapter the port does not ship. +s#^pstack is built for Cursor, and it also installs in Claude Code with .*a one-line note\.$#pstack installs in omp with `/marketplace add negoro26/pstack-omp`, then `/marketplace install pstack@pstack-omp`. Invoke skills as `/skill:`, such as `/skill:poteto-mode`. Read `skill://pstack-omp` for the live task and runtime contract and `skill://omp-mechanics` for pstack-specific OMP differences.# ## 10. Irreversible actions, and what the runtime actually enforces. Every replacement below ## exists because the original either contradicted another sentence in the same tree, named a @@ -471,6 +471,7 @@ s#Use for /swarm,#Use for /skill:swarm,# s#Use for /architect,#Use for /skill:architect,# s#Use for /figure-it-out,#Use for /skill:figure-it-out,# s#Use for /show-me-your-work,#Use for /skill:show-me-your-work,# +s#Use for /(create-formal-verification-skill|maintain-formal-verification-skill|statechart),#Use for /skill:\1,# s#"/architect with checkpoint,"#"/skill:architect with checkpoint,"# # `cursor location` is a Cursor composer affordance: the editor injects the caret position. omp is diff --git a/omp-port/stale-exempt.tsv b/omp-port/stale-exempt.tsv index 21546f0..f736bef 100644 --- a/omp-port/stale-exempt.tsv +++ b/omp-port/stale-exempt.tsv @@ -36,12 +36,8 @@ The synthesizer's quality check includes spot-verifying ci superseded: upstream there is no task `readonly` field\. Use forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. there is no task `readonly` field\.[[:space:]]*$ forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. The synthesizer returns a structured Accepted / Rejected / superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. -^Take the runners from the `architect runners` line in the forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. -^3\. Pick the runners\. Use the `arena runners` line in `~ forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. -After all Phase B candidates complete, choose one model fr forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. After all Phase B candidates complete, choose an exact dis forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. ^- `model`: the configured `interrogate reviewers` entry, forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. -If the Task tool rejects a configured entry, run that revi forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. `subagent_type`: `([^`]+)` forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. `subagent_type`: `([^`]+)` forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. agent mode \(`readonly: false`\) forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. @@ -58,6 +54,8 @@ One subagent per PR, not batched, each a Cursor cloud agen superseded: upstream `(claude|gpt|grok|gemini|opus)-[a-z0-9.]+-[a-z0-9.-]+` forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. One Cursor cloud agent superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. Cloud-agent PR tools default to draft, so set `draft: fals superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. +Fan out N parallel cloud workers\. upstream removed Swarm's standalone overview at eb4db2b; retain the cloud-worker translation if that sentence returns. +N is total workers, not the cloud concurrency limit\. upstream removed Swarm's Set N step at eb4db2b; retain the omp concurrency setting if that step returns. Restacks run in cloud\. A local restack at this scale take superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. Cloud agents cannot read the local store, so their briefs superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. A local root arms each tick as a real terminal `/loop`\. T superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. @@ -88,8 +86,6 @@ Re-read the execution playbook from trunk and the armed /g superseded: upstream - `Task` prompts that name a skill path superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. Tool calls \(Shell, Grep, MCP, etc\.\) superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. Use Glob to find directories and files, Grep to find key s superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. -Each returns `PASS`, `PASS\+NOTES` or `FAIL` and posts tha superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. -stop at the first one without a passing verdict, where bot superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. If the bottom PR is mergeable now, squash it with superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. push with `--force-with-lease` only after an `ls-remote` c superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. 4\. \*\*Prepare only the bottom PR\.\*\* Fetch current tru superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. diff --git a/plugins/pstack/README.md b/plugins/pstack/README.md index 1ae5f40..bd934dd 100644 --- a/plugins/pstack/README.md +++ b/plugins/pstack/README.md @@ -1,8 +1,8 @@ # pstack for oh-my-pi -Lauren Tan's pstack methodology, ported to omp. 56 skills, 23 playbooks, 24 principle +Lauren Tan's pstack methodology, ported to omp. 59 skills, 23 playbooks, 24 principle leaves, the `poteto-mode` pin, and the `poteto-agent` and `comment-sicko` agents. -53 skills are built from upstream. `omp-mechanics`, `pstack-omp`, and `setup-pstack` are port-owned. +56 skills are built from upstream. `omp-mechanics`, `pstack-omp`, and `setup-pstack` are port-owned. ## Install @@ -11,7 +11,7 @@ leaves, the `poteto-mode` pin, and the `poteto-agent` and `comment-sicko` agents /marketplace install pstack@pstack-omp ``` -That loads the 56 skills and both extensions, `potetomode` and `pstackpolicy`. The two agents are optional. +That loads the 59 skills and both extensions, `potetomode` and `pstackpolicy`. The two agents are optional. On omp 18.1.13 a marketplace plugin's `agents/` directory is scanned only through the `claude-plugins` discovery provider, and enabling that provider also loads every Claude Code plugin cached under `~/.claude/plugins`. Link the agents into omp's native root instead: From 4b7d1ecfd9814d3df0645c1161259dec347a9062 Mon Sep 17 00:00:00 2001 From: "mj.lee" Date: Fri, 23 Oct 2026 00:47:49 +0900 Subject: [PATCH 02/10] sync: build upstream pstack eb4db2b (0.15.30) --- .omp-plugin/marketplace.json | 6 +- omp-port/UPSTREAM | 2 +- plugins/pstack/package.json | 2 +- plugins/pstack/plugin.json | 2 +- plugins/pstack/skills/architect/SKILL.md | 2 +- plugins/pstack/skills/arena/SKILL.md | 4 +- .../create-formal-verification-skill/SKILL.md | 41 + plugins/pstack/skills/interrogate/SKILL.md | 2 +- .../interrogate/references/lead-judgment.md | 19 - plugins/pstack/skills/ketchup/SKILL.md | 20 +- .../SKILL.md | 58 + plugins/pstack/skills/poteto-help/SKILL.md | 14 +- .../skills/poteto-help/references/recipes.md | 4 +- .../skills/poteto-mode/playbooks/shipping.md | 4 +- .../SKILL.md | 8 - .../principle-type-system-discipline/SKILL.md | 7 +- plugins/pstack/skills/sharpen/SKILL.md | 13 +- plugins/pstack/skills/statechart/SKILL.md | 36 + .../statechart/scripts/chart-to-tla.mjs | 236 ++ .../statechart/scripts/compare-charts.mjs | 104 + .../pstack/skills/statechart/scripts/lib.mjs | 723 +++++ .../statechart/scripts/model/OutputFolder.cfg | 6 + .../scripts/model/OutputFolder.map.mjs | 106 + .../statechart/scripts/model/OutputFolder.tla | 176 ++ .../skills/statechart/scripts/statechart.mjs | 749 +++++ .../scripts/test/statechart.test.mjs | 2790 +++++++++++++++++ .../skills/statechart/scripts/test/sync.mmd | 19 + .../skills/statechart/scripts/test/upload.mmd | 16 + .../statechart/scripts/tlc-to-chart.mjs | 1347 ++++++++ plugins/pstack/skills/swarm/SKILL.md | 5 - .../skills/typescript-best-practices/SKILL.md | 2 +- .../references/patterns.md | 8 +- 32 files changed, 6464 insertions(+), 67 deletions(-) create mode 100644 plugins/pstack/skills/create-formal-verification-skill/SKILL.md create mode 100644 plugins/pstack/skills/maintain-formal-verification-skill/SKILL.md create mode 100644 plugins/pstack/skills/statechart/SKILL.md create mode 100644 plugins/pstack/skills/statechart/scripts/chart-to-tla.mjs create mode 100644 plugins/pstack/skills/statechart/scripts/compare-charts.mjs create mode 100644 plugins/pstack/skills/statechart/scripts/lib.mjs create mode 100644 plugins/pstack/skills/statechart/scripts/model/OutputFolder.cfg create mode 100644 plugins/pstack/skills/statechart/scripts/model/OutputFolder.map.mjs create mode 100644 plugins/pstack/skills/statechart/scripts/model/OutputFolder.tla create mode 100644 plugins/pstack/skills/statechart/scripts/statechart.mjs create mode 100644 plugins/pstack/skills/statechart/scripts/test/statechart.test.mjs create mode 100644 plugins/pstack/skills/statechart/scripts/test/sync.mmd create mode 100644 plugins/pstack/skills/statechart/scripts/test/upload.mmd create mode 100644 plugins/pstack/skills/statechart/scripts/tlc-to-chart.mjs diff --git a/.omp-plugin/marketplace.json b/.omp-plugin/marketplace.json index e337b8e..f6db0f2 100644 --- a/.omp-plugin/marketplace.json +++ b/.omp-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "pstack, poteto's engineering methodology for Cursor, ported to oh-my-pi. Model and provider agnostic.", - "version": "0.15.25" + "version": "0.15.30" }, "plugins": [ { "name": "pstack", - "description": "56 skills, 23 playbooks, 24 principles, the poteto-mode pin, and two agents. Upstream cursor/plugins pstack at e5136d8.", - "version": "0.15.25", + "description": "59 skills, 23 playbooks, 24 principles, the poteto-mode pin, and two agents. Upstream cursor/plugins pstack at eb4db2b.", + "version": "0.15.30", "category": "development", "license": "MIT", "source": "./plugins/pstack" diff --git a/omp-port/UPSTREAM b/omp-port/UPSTREAM index f277c07..a5d6977 100644 --- a/omp-port/UPSTREAM +++ b/omp-port/UPSTREAM @@ -1 +1 @@ -e5136d82418d2db3165ea1050d17577cd618cc05 +eb4db2b55b2b7dec3d9282754cddf0a56bf4ff10 diff --git a/plugins/pstack/package.json b/plugins/pstack/package.json index 515fb5a..465cf41 100644 --- a/plugins/pstack/package.json +++ b/plugins/pstack/package.json @@ -1,6 +1,6 @@ { "name": "pstack", - "version": "0.15.25", + "version": "0.15.30", "license": "MIT", "omp": { "extensions": [ diff --git a/plugins/pstack/plugin.json b/plugins/pstack/plugin.json index 199469f..4c39185 100644 --- a/plugins/pstack/plugin.json +++ b/plugins/pstack/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "pstack", - "version": "0.15.25", + "version": "0.15.30", "description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence.", "author": { "name": "Lauren Tan" diff --git a/plugins/pstack/skills/architect/SKILL.md b/plugins/pstack/skills/architect/SKILL.md index b02c108..fd95ff9 100644 --- a/plugins/pstack/skills/architect/SKILL.md +++ b/plugins/pstack/skills/architect/SKILL.md @@ -28,7 +28,7 @@ Skip Phase A only when the work is genuinely greenfield with no surrounding syst Run the **arena** skill with the design-sketch task and the Phase A grounding artifacts. Pass `references/runner-prompt.md` as each runner's prompt. -Take the runners from the exact discovered runner agents.. If the rule or that line is missing, use your strongest judgment model and your fast code model. Alias and rejected entries follow the runner rules in the **arena** skill's Phase A. +Use the same exact discovered runner agents as Arena, with the architect role in each brief. Each agent's frontmatter or `task.agentModelOverrides[]` selects its model; the task item has no `model` field. Design it twice. Require at least two structurally distinct candidates before synthesis, even when the first looks sufficient. This is the **exhaust-the-design-space** principle skill made concrete. Whole-shape alternatives, not point fixes inside one shape. diff --git a/plugins/pstack/skills/arena/SKILL.md b/plugins/pstack/skills/arena/SKILL.md index 6f7707e..53a9aa2 100644 --- a/plugins/pstack/skills/arena/SKILL.md +++ b/plugins/pstack/skills/arena/SKILL.md @@ -14,7 +14,7 @@ Every candidate gets the same prompt, so the prompt is the contract. 1. State the artifact each candidate is producing. 2. Derive the rubric, 3-6 concrete gradeable criteria for what success looks like on *this* task. It is the picker's tool in Phase D. Candidates only see the task. -3. Pick the runners. Use the exact discovered runner agents in `~/.omp/agent/config.yml`. If the rule or that line is missing, default to one each on your strongest judgment model and your fast code model. An `auto` or `inherit-parent` entry in this line or the cross-judge line means the parent model, so omit `model` for it. If the Task tool rejects a configured entry, run that seat on its family's default and say so. Families go by prefix: `claude-*`, and `grok-*` with `cursor-grok-*` and `composer-*`. A refused `composer-*` entry runs on your fast code model. With no family match, use your strongest judgment model. If it rejects a default, use the closest valid slug of the same family from its error message. Spawn more runners when the arena covers multiple design directions. Use the same model N times when the work is generation-bound rather than judgment-sensitive. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. +3. Pick the runners. Resolve exact discovered agent names and inspect each agent's frontmatter or `task.agentModelOverrides[]`. Prefer distinct configured model families when the roster supplies them; otherwise keep the independent contexts and report weaker model diversity. A model override cannot create an agent. Spawn more runners when the arena covers multiple design directions. Use the same model N times when the work is generation-bound rather than judgment-sensitive. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. 4. Give each candidate its own output location (a git worktree where possible, otherwise `/tmp/arena-/candidate-/`), per the **separate-before-serializing-shared-state** principle skill. ## Phase B: Fan out @@ -25,7 +25,7 @@ If a candidate produces no output, proceed with N-1 and note the dropout. ## Phase C: Cross-judge -After all Phase B candidates complete, choose one model from an exact discovered judge agent whose configured model differs from the parent's when the roster provides one, in `~/.omp/agent/config.yml`. If the rule or that line is missing, choose from your strongest judgment model and your fast code model. Prefer a different model family from the parent's. Start one judge in one `task` call with one item in `tasks[]`, the required shared `context`, and a brief that grants read tools only. It sees the rubric and the candidates by path label, scores each criterion, and recommends a base with rationale. It runs in parallel with your reading in Phase D. Don't spawn it while candidates are still writing. +After all Phase B candidates complete, choose an exact discovered judge agent. Start the judge in one `task` call with one item in `tasks[]`, the required shared `context`, and that exact agent name. Its frontmatter or `task.agentModelOverrides[]` selects the model; the task item has no `model` field. The brief carries read-only posture, grants only the tools that discovered agent actually has, forbids writes, and has no task `readonly` field. Prefer a different configured model family when the operator's model policy and roster supply one; otherwise keep the exact agent and report weaker model diversity. It sees the rubric and the candidates by path label, scores each criterion, and recommends a base with rationale. It runs in parallel with your reading in Phase D. Don't spawn it while candidates are still writing. ## Phase D: Pick a base diff --git a/plugins/pstack/skills/create-formal-verification-skill/SKILL.md b/plugins/pstack/skills/create-formal-verification-skill/SKILL.md new file mode 100644 index 0000000..17aae2e --- /dev/null +++ b/plugins/pstack/skills/create-formal-verification-skill/SKILL.md @@ -0,0 +1,41 @@ +--- +name: create-formal-verification-skill +description: "Generate a project-local skill that formally verifies the code where a bug costs the most and tests reach the least, in any language, with whichever verification tool fits. Use for /skill:create-formal-verification-skill, \"model-check this race\", \"prove this invariant\", or when concurrency, retries, state machines, parsers, or unsafe code need more than tests." +disable-model-invocation: true +--- + +# Create a formal verification skill + +A test checks the cases that someone thought to write. Formal verification checks every case in a stated scope: every order of steps in a model, every input up to a bound, or every input. This skill finds the part of the repo where that matters most, verifies one property of it, and leaves a project-local skill that lets the next agent check a change against that property. Write that skill for an agent who reads it cold, mid-change, and has never seen your work. The user must be able to trust the result and act on it without learning the verification language. + +## Choose the target from the code + +Answer from the codebase, and ask the user only what you cannot observe. If the user named an area or a bug, start there. + +A good target is a promise the code makes, where a break harms users and tests rarely reach the case that breaks it. Choose the strongest such target in the repo, not the one that is easiest to verify. + +## Let the question choose the tool + +Pick the tool that answers the question at the lowest cost: a model checker, a proof assistant, an SMT-backed verifier, or another tool. Whether events can happen in a bad order usually suits a model checker over a small model of the design, and TLA+ is the default for that in any language. Whether the code itself behaves for every input, as with memory safety, overflow, or a data-structure invariant, usually suits a verifier that runs on the code. If no verifier runs on this language, say so, and test the real code with generated inputs, not a hand model of the function. A property that must hold at every size, where a bound is not enough, can need a proof. Prefer a tool that the repo's developers can install and run, and tell the user why you chose it. + +## What makes it good + +- **True to the code.** Every part of the model or proof points to code that a reviewer can open. What the verification treats as one indivisible step is one indivisible step in the code. Names come from this repo. +- **Able to fail.** A check that you have never seen fail proves nothing. Show each property failing on a broken variant of the code it guards, such as a removed check or a reordered step. +- **Findings stay findings.** When the current code breaks the property, report it with the counterexample in the code's terms. Do not weaken the property or the model until it passes, and do not change product code unless the user asks. +- **Honest about scope.** Say what is verified, under which bounds and assumptions, and what is left out. A pass covers the model or the bound, not the whole system. +- **Readable without the tool.** State the property and the harm in one plain sentence each. Tell a counterexample as a short sequence of steps in the code's words. +- **Cheap to rerun.** One command, a reproducible tool version, and a run short enough that the next agent will use it on every relevant change. +- **Kept in step with the code.** Record which files and functions each check covers, and the commit at which you last confirmed that the verification describes them. When the covered code changes after that commit, the next agent can see that the verification needs another look. + +## Generate the verify skill + +Write a project-local skill with frontmatter that names the area and says when to use it. Without frontmatter, the skill never registers. From the skill alone, the next agent must be able to tell whether a change touches the verified code, run the check, tell a real bug from a modeling error, and update the verification when a change is intended. Put the coverage record and the sync commit in the skill, and tell the next agent to move the sync commit forward only after it confirms that the verification describes the code at the new commit. Use this repo's real commands, paths, and nouns, and leave no placeholders. + +## Prove it before you hand off + +Follow the generated skill once, as the next agent would, on a scratch change that breaks the property in the real code. It must catch the break. Then drop the scratch change and confirm that the check is back where it started: passing, or failing only on the findings you will report. A verify skill that was never run is a draft. If the skill misses the break or a step does not run, fix the skill or the verification, not the scratch change. + +## Hand off + +Tell the user what you verified, which tool you used and why, the bounds, what the check found in plain words, how to run it, and what it does not cover. Suggest a second target only if you found a close one. Point the user at `/skill:maintain-formal-verification-skill` for bringing the verification back in line after changes that did not recheck it. diff --git a/plugins/pstack/skills/interrogate/SKILL.md b/plugins/pstack/skills/interrogate/SKILL.md index a84d008..ac1cb54 100644 --- a/plugins/pstack/skills/interrogate/SKILL.md +++ b/plugins/pstack/skills/interrogate/SKILL.md @@ -33,7 +33,7 @@ Launch all reviewers in a single message with the Task tool, one per configured Each reviewer gets `agent: generalPurpose`, read-only posture carried in the brief, and `model` set to its configured entry, or its table default when there is no configured line. Omit `model` for an `auto` or `inherit-parent` entry, so that reviewer runs on the parent model. -If the Task tool rejects a configured entry, run that reviewer on its family's table default and say so. Families go by prefix: `claude-*`, and `grok-*` with `cursor-grok-*` and `composer-*`. A refused `composer-*` entry runs on Reviewer B's default. With no family match, use Reviewer A's default. If it rejects a table default, spawn with the closest equivalent from the valid slugs in its error message (prefer the same family and reasoning tier), and open a separate PR to update the default table. Do not block the review on the slug issue. Never treat an alias entry as a rejected slug or apply either fallback to it. +If the requested exact reviewer is absent, use another discovered reviewer with the same brief and report the missing model diversity. A model override cannot create an agent. Read `references/reviewer-prompt.md` and fill in the same template for every reviewer with the stated intent, the diff or file contents, the rubric from `references/rubric.md`, and the code-quality lens from `references/code-quality-review.md`. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. diff --git a/plugins/pstack/skills/interrogate/references/lead-judgment.md b/plugins/pstack/skills/interrogate/references/lead-judgment.md index 5ce5260..3b41346 100644 --- a/plugins/pstack/skills/interrogate/references/lead-judgment.md +++ b/plugins/pstack/skills/interrogate/references/lead-judgment.md @@ -1,18 +1,5 @@ # Lead Judgment Framework -You are the lead reviewer. The configured reviewers have produced their findings. Apply pragmatic engineering judgment. Don't aggregate. Filter, contextualize, and decide. - -## Why This Step Matters - -Adversarial reviewers are useful because they're aggressive. But aggression without context produces noise. The reviewers only saw a slice of the codebase and a one-paragraph intent statement. They don't know: - -- What was already tried and rejected -- What constraints exist outside the code (timeline, dependencies, migration plans) -- Which parts of the code are temporary scaffolding vs. permanent architecture -- What the next PR in the stack will address - -You have the full conversation context. Use it. - ## Filtering Principles ### Nitpick Gravity @@ -50,9 +37,3 @@ Don't dismiss findings just because they're uncomfortable. The whole point of ad - You read the finding and think "...yeah, actually" Be especially careful about dismissing security findings and correctness bugs. These deserve more scrutiny even when they come from a single model. - -## Verdict Calibration - -A good verdict is useful, not comprehensive. The user should be able to read the "Act On" section, fix those issues, and ship with confidence. If your "Act On" list has more than 5 items, you're probably not filtering hard enough. - -The "Dismissed" section is not busywork. It's a trust mechanism. Showing the user what you rejected and why lets them override your judgment where they disagree. This is more valuable than hiding the rejected findings. diff --git a/plugins/pstack/skills/ketchup/SKILL.md b/plugins/pstack/skills/ketchup/SKILL.md index f4f407a..41fc192 100644 --- a/plugins/pstack/skills/ketchup/SKILL.md +++ b/plugins/pstack/skills/ketchup/SKILL.md @@ -1,12 +1,12 @@ --- name: ketchup -description: "Catch the user up on what changed in this chat since their last message, in a few plain sentences and small visuals. Give each action item just enough context to make the call. Use for /ketchup." +description: "Catch the user up on what changed in this chat since their last message, in plain words and small visuals. List every item waiting on them with what they need to decide it from the reply alone. Use for /ketchup." disable-model-invocation: true --- # Ketchup -Catch the user up on what changed in this chat since the last message they wrote before `/skill:ketchup`, and help them understand each change, not only where it stands. Notifications and subagent results are not their messages. Assume the user remembers their last message and nothing after it. +Catch the user up on what changed in this chat since the last message they wrote before `/skill:ketchup`, and help them understand each change, not only where it stands. Then lay out every call waiting on them so they can make it from your reply alone. Notifications and subagent results are not their messages. Assume the user remembers their last message and nothing after it. Use [`/skill:bro`](../bro/SKILL.md)'s voice, not its task: plain words, short sentences, no jargon, like one person catching up another. Say what changed for the user, not how you did it. Say "the tests pass", not "CI is green". Leave out hashes, file paths, commands, build numbers, and test counts unless the user needs one to act. Call each PR, branch, or agent by a name the user would know, not by a label you invented. If a fact needs context to make sense, give the context or leave the fact out. @@ -14,24 +14,30 @@ Use [`/skill:bro`](../bro/SKILL.md)'s voice, not its task: plain words, short se Keep this turn read-only. Make visuals only from what you already have: do not change code, start an app or a job, or post anything. Collect your own work since that message, reports from subagents, and messages from anyone else in this chat. Check the current state of each PR, CI run, or agent this chat touched. Check each number you will show against the facts it comes from, and say so when you correct one. -An action item is a decision or step only the user can take, such as a product choice, an approval, an irreversible action, or access you lack. Include any still waiting on the user, even one raised before their last message. Work you can still do yourself is not an action item. Never make up a call to fill the reply. A step the user did not ask for and the work does not need is not an action item, unless it is irreversible and waiting on the user: give it one action line, no visual, and skip what happens if the user does not answer. +An action item is a call or step only the user can take, such as a product choice, an approval, an irreversible action, or access you lack. Include any still waiting on the user, even one raised before their last message. Work you can still do yourself is not an action item. Never make up a call to fill the reply. A step the user did not ask for and the work does not need is not an action item, unless it is irreversible and waiting on the user: give it one line and no visual. + +One call can cover many items, such as each PR in a set awaiting merge. For each item, collect its link, what it changes for users, the evidence behind your pick, its risk, and your pick. Write "unknown" for a fact the call needs that you do not know. Leave out a link you do not have. ## Write the reply -Keep the whole reply well under 150 words, not counting visuals. When something is easier to grasp as a picture than as sentences, draw it: how a design or system works, a PR stack or dependency chain with each PR's state, a before and after of a fix, or options side by side. Never draw a status list or boxes with nothing connecting them. Never give a fact twice, in text or across visuals. Leave out work that changed nothing for the user. When words run short, cut from what happened before you cut from the action items. +Spend words on the calls, not on the story. Keep what happened under 100 words, not counting pictures, and shorter when less happened. Leave out work that changed nothing for the user. Give each item about 30 words. The reply grows with the number of items: two items need well under 150 words in all, not counting pictures, and thirty need far more. Never cut items or shrink them to stay under a word count: no count in place of a list, and no link in place of an item. A link can add depth, but the reply alone must be enough to decide. -Open with one sentence that says how many things need the user, or that nothing does. If nothing happened and nothing waits on the user, say so and stop. +Open with one short sentence that names what waits on the user, or says nothing does. The action items give the detail. If nothing happened and nothing waits on the user, say so and stop. Then show what happened, pictures first. Embed inline the screenshots and videos the work produced, then any diagram that explains a change. Then sum up in one short bullet per thing the user asked for: what came of it, and whether it is done, still running, failed, or not checked. Write each line the way you would say it out loud: "Search was slow from a missing index", not "Search review: done" or "Slow search: missing index". Name any bug found and fixed on the way. Each line must stay true when read alone. Never mark something done that still waits on the user, and leave out anything the action items say. Under the summary, give at most three short lines that change what the user knows or must do. Then name in one line any work left that you can do yourself. -Then list the action items as plain bullets, never as a diagram or box. Give each two lines, or one for an unasked irreversible step: the decision with your recommendation and why, then what happens if the user does not answer. Ask the call the user owns, such as merge, approve, or pick. A step you would take first goes in your recommendation. So does anything that must happen first, and anything that cannot be undone. Write "unknown" for a part you do not know. Link to the detail. +Then list the action items. Ask the call the user owns, such as merge, approve, or pick, and give your pick. A step you would take first goes in your pick. So does anything that must happen first, and anything that cannot be undone. Give the number or link of each PR, issue, or agent a call is about, also in a call between options. + +Fit the shape to the call. A call about one item is one bullet: the call, your pick, what it changes for users, the evidence behind your pick, and its risk, with the item named once. When items share a call, write the call and your pick once, then give each item its own bullet, riskiest first: its link and name, what it changes for users, the evidence behind your pick, and its risk. Name an item's pick only where it differs from the call's. Give each item's risk even when it is low, such as "Low risk: only the colors change." Say what waiting costs only when it is more than a delay, such as a deadline or lost data. -Show a choice after the list. If the screenshots above already show its options, write each option's gain and cost under its screenshot instead of drawing a new visual; otherwise, compare the options side by side, with the current state when there is one. Give every option the work weighed, each with what it gets the user and what it costs. Keep them out of the rest of the text. +For a call between options, give every option the work weighed, each with what it gets the user and what it costs, and the current state when there is one. Those costs are the call's risk, so add no separate risk line. When the screenshots above show the options, write each option's gain and cost under its screenshot. ## Visuals A visual is a picture: a screenshot, a video, a Mermaid diagram, a chart or diagram from `/visualize`, or a generated image. A Markdown table is not a visual, even with before and after columns. +When something is easier to grasp as a picture than as sentences, draw it: how a design or system works, a PR stack or dependency chain with each PR's state, a before and after of a fix, or options side by side. Never draw a status list, a list of action items, or boxes with nothing connecting them. Never give a fact twice, in text or across visuals. + Keep each visual small enough to read at a glance: at most six boxes, with at most eight words in each. Write labels as plain words a person would say, not tags like "Tests: pass". A fact that needs a sentence belongs in the text, or nowhere. Embed screenshots and videos inline when the change is visible, not only as links. If a visible change has none, mark its summary line "No screenshot yet," and say there what changed on screen. Draw diagrams and charts from facts you have: a Mermaid diagram for how things connect and a `/visualize` chart for numbers. Never invent a number, and never draw or generate a picture of a screen you did not capture. Link only artifacts that exist. diff --git a/plugins/pstack/skills/maintain-formal-verification-skill/SKILL.md b/plugins/pstack/skills/maintain-formal-verification-skill/SKILL.md new file mode 100644 index 0000000..8fb5e05 --- /dev/null +++ b/plugins/pstack/skills/maintain-formal-verification-skill/SKILL.md @@ -0,0 +1,58 @@ +--- +name: maintain-formal-verification-skill +description: "Bring a project's formal verification back in line with the code after the code changes: find the commits that touched verified code, update the model, harness, or properties to describe the code as it is now, rerun, and say whether each failure is drift in the verification or a bug in the code. Use for /skill:maintain-formal-verification-skill, \"is the model still true to the code\", or after merges and refactors that touched verified code without rechecking it." +disable-model-invocation: true +--- + +# Maintain a formal verification skill + +A formal check proves a property of what it describes. When the code changes and the verification does not, the check can keep passing while it describes code that no longer exists. That pass is the dangerous case, because it looks like a proof. This skill is the upkeep loop for a verify skill that `/skill:create-formal-verification-skill` generated, or for any project-local skill that runs a model checker, prover, or code-level verifier against named code. The project-local skill handles a change that an agent makes while it uses that skill. This skill handles the changes that skipped it: merges, edits made without the skill, and drift that builds up across many commits. + +## Outcomes + +Give each check one outcome, and say which: + +- **in sync**: no change since the last sync alters what the check describes, and the check passes. +- **updated**: the code changed, the verification now describes the new code, and the check passes. +- **bug**: the verification describes the current code, and the code breaks the property. This is a finding about the code. It is not drift. +- **blocked**: the check could not be brought back in line or could not run. Say exactly why. + +A check can be both updated and bug: the update makes the verification describe the code again, and then the property fails on it. + +## Edit scope + +Edit only the verification: models, harnesses, property files, and the verify skill itself. Never edit product code during a run, unless the user asks you to. When a property fails on the current code, do not weaken the property or the model to make it pass. Report the failure. + +## Pass + +1. **Find the checks.** A verify skill has no fixed name, so find it by what it does: a project skill that runs a model checker, a prover, or another verifier against named code. Read each one you find, or the one the user named. For each check, collect the property, the code it covers, the bounds and assumptions, the command that runs it, and the commit where it was last confirmed to describe the code. If no verify skill exists, stop and point at `/skill:create-formal-verification-skill`. If the skill does not record a sync commit, use the last commit that changed the verification files, and say that you did. + +2. **Run it as it is.** Run each check once before you change anything, and record the result. A pass here does not tell you that the code is correct. It tells you that the old description still holds. A failure here can be drift or a bug. Do not decide which until step 5. + +3. **Read every change to covered code.** Diff the covered files from the sync commit to the current commit, and read each commit message. Also look for code that moved: a covered function that is renamed, split, or moved to another file is still covered code. Also look for new code that touches the state that a check guards, such as a new function that writes the same fields. For each change, decide which of these it is: + - **No effect.** The change does not alter anything the verification describes, such as formatting, comments, or code paths that the verification does not cover. Check this against the model or harness, not against the commit message. + - **The verification no longer describes the code.** A step that was one indivisible action is now two, a branch was added or removed, a check moved, a new path reaches the guarded state, or a value now has a different range. The model or harness must change. + - **The promise changed on purpose.** The change alters what the code promises, and evidence in the change says that this is intended: the doc comment, the tests, the changelog, or the commit message. The property must change, and you must cite that evidence. + - **The promise may be broken.** The code changed, and no evidence says that the promise changed. Treat a failure that follows as a bug candidate. + +4. **Update from the code, not from the result.** Change the model or harness so that each changed part again points to code that a reviewer can open, and keep one indivisible step in the code as one step in the verification. Write the update from the code before you rerun, and do not shape it by what makes the check pass. Change a property only for a promise that changed on purpose. Write down the old wording, the new wording, and the evidence. Keep the bounds unless the change makes them wrong. If you must reduce a bound, say so in the report. + +5. **Rerun and judge each result.** + - **Pass:** the check can now pass on a description that has lost the ability to fail. Break the new code the way the property guards against, such as removing a new check or reordering a new step, and confirm that the check catches it. Then drop the break. + - **Fail:** replay the counterexample against the real code, step by step. If a step cannot happen in the code, the verification is wrong. Fix the verification, and say what was wrong. If every step can happen, it is a bug. Name the commit that introduced it, tell the counterexample as a sequence of steps in the code's own words, and state the harm in one sentence. If a cheap test or reproduction against the real code shows the bug, add it to the report as evidence. Do not commit it to the product code. + +6. **Update the verify skill.** Update the code that each check covers, the commands, the bounds, the assumptions, and the sync commit. Set the sync commit to the commit that you checked. Do not move the sync commit for a blocked check, and say so in the report. Then follow the verify skill once as the next agent would, so that you know its steps still run. + +## Report + +Tell the user, for each check: + +- the outcome, and the commits that you read; +- what changed in the verification and why, with a short reason for each change: the code changed shape, or the promise changed on purpose (with the evidence); +- the rerun result, and the break that you used to show the updated check can still fail; +- each bug, in plain words: the counterexample as steps in the code's words, the harm, and the commit that introduced it; +- what the verification still does not cover, including new code that it should cover and does not. + +Say outright which failures were drift in the verification and which were bugs in the code. For a check that passed before you updated it and fails after, say that the old pass was on a stale description. + +Put the verification updates in a change of their own, separate from any product fix. diff --git a/plugins/pstack/skills/poteto-help/SKILL.md b/plugins/pstack/skills/poteto-help/SKILL.md index a832b0a..8844afa 100644 --- a/plugins/pstack/skills/poteto-help/SKILL.md +++ b/plugins/pstack/skills/poteto-help/SKILL.md @@ -26,6 +26,7 @@ Check the state that changes the answer, and mention it only when it does: - No `task.agentModelOverrides` in `~/.omp/agent/config.yml` means `/skill:setup-pstack` hasn't run for this user, so every role uses its default model. - No `verify-*` skill or other app harness in the project means agents have no scripted way to drive the app. Mention `/skill:create-verification-skill` when the question is about proving a change works. +- No formal verification skill in the project means no property is checked past what tests reach. Mention `/skill:create-formal-verification-skill` when the question is about a race, an invariant, or another case tests rarely hit. When one exists and the code it covers changed after the commit it records as last in sync, mention `/skill:maintain-formal-verification-skill` if the question touches that code. When the model rule is missing and it matters, ask whether the user wants to pick a model for each role and a reasoning budget now. It matters when the user is new, the question is about setup or cost, or the answer depends on which models run. Ask at most once per chat. If the need is also unclear, ask both questions together. Offer two choices: @@ -35,14 +36,16 @@ When the model rule is missing and it matters, ask whether the user wants to pic ## Get set up 1. Install with `/add-plugin pstack` in chat, or from Customize in the sidebar. -2. Run [`/skill:setup-pstack`](../setup-pstack/SKILL.md). It asks for a reasoning budget, maps a model to each role, and writes a rule. The rule applies to new chats. +2. Run [`/skill:setup-pstack`](../setup-pstack/SKILL.md). It asks for a reasoning budget, maps a model to each role, and writes a rule. The rule applies to new chats. Then it offers once each to generate a verification skill and a formal verification skill for the project, unless the project already has one. 3. Start a real task with `/skill:poteto-mode`, a goal, and a check that can pass or fail. Installing changes nothing until the user invokes a skill. Only `/skill:setup-pstack` loads from the user's words. The [README](../../README.md) and [guide page 1](../../docs/guide/01-setup.md) have the details. Offer to word their first prompt with them, per [`references/prompting.md`](references/prompting.md). If cost is the worry, say where the tokens go and how to spend fewer. pstack spends extra tokens on subagents and review panels. Rerun `/skill:setup-pstack` and pick a smaller budget or cheaper models. A role set to `auto` or `inherit-parent` runs on the chat's model, which saves tokens when the chat runs on Auto or a cheaper model. A shorter panel list runs fewer subagents, one for each entry. Save `/skill:poteto-mode` for work that needs rigor. -pstack is built for Cursor. Its skills use the Agent Skills format, so other tools can read them. But most workflow skills, including `/skill:poteto-mode`, `/skill:how`, `/skill:why`, and `/skill:teach`, spawn Cursor subagents with per-role models, and Custom Modes and `/loop` are Cursor features, so those parts may not work there. +pstack installs in omp with `/marketplace add negoro26/pstack-omp`, then `/marketplace install pstack@pstack-omp`. Invoke skills as `/skill:`, such as `/skill:poteto-mode`. Read `skill://pstack-omp` for the live task and runtime contract and `skill://omp-mechanics` for pstack-specific OMP differences. + +Other tools can read the skills, because they use the Agent Skills format. But most workflow skills, including `/skill:poteto-mode`, `/skill:how`, `/skill:why`, and `/skill:teach`, spawn Cursor subagents with per-role models, and Custom Modes and `/loop` are Cursor features, so those parts may not work there. ## Start a task with `/skill:poteto-mode` @@ -64,12 +67,12 @@ The default answer is `/skill:poteto-mode`, which runs most of the others when i |---|---| | Do any non-trivial task with rigor | [`/skill:poteto-mode`](../poteto-mode/SKILL.md) | | Check that the agent understood their goal and the problem before it starts | [`/skill:in-your-own-words`](../in-your-own-words/SKILL.md) | -| Aim a task higher before starting: more expert, more outcome-focused, not overspecified | [`/skill:sharpen`](../sharpen/SKILL.md) | +| Get a prompt rewritten deeper, not bigger, before sending it: what a top professional would add, their raw material and methods, and an outside check. It returns the prompt and doesn't do the task | [`/skill:sharpen`](../sharpen/SKILL.md) | | Know how code works now, or where new code should live | [`/skill:how`](../how/SKILL.md) | | Know why code is shaped this way, or where a number came from | [`/skill:why`](../why/SKILL.md) | | Understand a change or subsystem, explained plainly | [`/skill:teach`](../teach/SKILL.md) | | Catch up on their own recent work on a topic | [`/skill:recall`](../recall/SKILL.md) | -| Catch up on what changed in this chat since their last message, in a few plain sentences and small visuals, with enough context to decide each action item | [`/skill:ketchup`](../ketchup/SKILL.md) | +| Catch up on what changed in this chat since their last message, in plain words and small visuals, with every item waiting on them listed and decidable from the reply alone | [`/skill:ketchup`](../ketchup/SKILL.md) | | Know what a small diff could break outside itself | [`/skill:blast-radius`](../blast-radius/SKILL.md) | | Settle types and module shape before code that crosses a function boundary | [`/skill:architect`](../architect/SKILL.md) | | Get several attempts at one brief, merged into the best one | [`/skill:arena`](../arena/SKILL.md) | @@ -83,6 +86,9 @@ The default answer is `/skill:poteto-mode`, which runs most of the others when i | Hear the last reply again in plain words | [`/skill:bro`](../bro/SKILL.md) | | Give agents a scripted way to drive the app and prove behavior | [`/skill:create-verification-skill`](../create-verification-skill/SKILL.md) | | Bring a verification skill and its feature map back in line with the app | [`/skill:maintain-verification-skill`](../maintain-verification-skill/SKILL.md) | +| Formally verify a race, invariant, or other property that tests rarely reach | [`/skill:create-formal-verification-skill`](../create-formal-verification-skill/SKILL.md) | +| Bring a formal verification back in line after the code it covers changed, and learn whether a failure is drift or a bug | [`/skill:maintain-formal-verification-skill`](../maintain-formal-verification-skill/SKILL.md) | +| Show a PM or designer what the user sees as a chart checked by a model, with each bug drawn as a numbered path, turn an agreed chart into a TLA+ skeleton, or audit the code against its TLA+ spec | [`/skill:statechart`](../statechart/SKILL.md) | | Vet a performance number before reporting or acting on it | [`/skill:benchmark-checklist`](../benchmark-checklist/SKILL.md) | | Run a large or cross-cutting change, or one to review after stepping away | [`/skill:figure-it-out`](../figure-it-out/SKILL.md) | | Keep a decision log during a run, and review it afterward | [`/skill:show-me-your-work`](../show-me-your-work/SKILL.md) | diff --git a/plugins/pstack/skills/poteto-help/references/recipes.md b/plugins/pstack/skills/poteto-help/references/recipes.md index c05f57c..b78a7c1 100644 --- a/plugins/pstack/skills/poteto-help/references/recipes.md +++ b/plugins/pstack/skills/poteto-help/references/recipes.md @@ -6,7 +6,7 @@ Swap in the real paths, skills, and done checks. Informal wording works. - `/poteto-mode read . restate the underlying issue in your own words, in plain english.` - `/in-your-own-words ` -- `/sharpen ` +- `/sharpen ` - `/poteto-mode investigate why . give me what we know, what data you used, and your best hypotheses. don't change any code yet.` - `use /how to understand . then use /why to find out why it broke recently.` - `/recall my work on from last week, then read .` @@ -34,6 +34,7 @@ Swap in the real paths, skills, and done checks. Informal wording works. ## Review and ship - `/interrogate the whole branch, but skeptically. don't change anything yet. no nitpicks unless it's a real bug or regression.` Read the dismissals too. +- `/create-formal-verification-skill prove holds in , even when interleave or retry.` After merges or refactors in that code: `/skill:maintain-formal-verification-skill`. - `/swarm check every package under against its check script. one worker per package. one report.` - `/poteto-mode open the pr. small ordered commits, evidence in the description.` - `/poteto-mode babysit this pr. get it green.` For status only: `/poteto-mode check on pr . anything outstanding?` @@ -43,6 +44,7 @@ Swap in the real paths, skills, and done checks. Informal wording works. - `/poteto-mode im going to bed. in a fresh worktree off . done means . keep a decision log. don't ask me before committing. /loop until done. if you're truly stuck after a few hours, stop and write up why.` - `/show-me-your-work catch me up on what you did last night.` Read its Attention section first. +- `/skill:ketchup` catches you up on this chat since your last message and lists every call that waits on you. - `/poteto-mode full autopilot on this queue. each item is independent.` - `/poteto-mode autopilot these changes but stack them, don't ship. i'll land the stack.` - `/reflect capture what we learned so the next run doesn't repeat it.` Approve only edits that change a future decision. diff --git a/plugins/pstack/skills/poteto-mode/playbooks/shipping.md b/plugins/pstack/skills/poteto-mode/playbooks/shipping.md index d6d64a7..7f1153c 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/shipping.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/shipping.md @@ -2,8 +2,8 @@ **You own what lands. Verify each PR independently, land only the verified run from the root, then keep your hands off the stack.** -1. **Resolve the forge, then verify each PR independently before it lands.** GitHub CLI (`gh`) is the default. If `command -v origin` succeeds and Origin can resolve the repository, use `origin pr` for every PR operation. Otherwise stay on `gh` and record the fallback. Never require Graphite (`gt`). One verifier per PR, an isolated subagent (`isolated: true`), runs the change the way a user would with the matching control skill (such as the `browser` eval prelude or bash with a unique `name`, a `ready` check, and `proc://` state from omp's built-in tools), parent against head, and posts `PASS`, `PASS+NOTES`, or `FAIL` on that PR. Only a verdict from an agent that did not write the code counts, because an author checks what it meant to build. Green CI and an approving bot review are not verdicts, because neither runs the change as a user would. -2. **Land only the contiguous verified run rooted at the bottom.** Walk up from the lowest unmerged PR and stop at the first one without `PASS` or `PASS+NOTES`. The PRs below it are the run, and the top one is the ceiling. A verified PR above an unverified one waits, because it cannot land without the code below it. One whose branch neither holds nor needs that code can move onto trunk and land on its own. It needs a fresh verdict first, because its old one ran with that code beneath it and trunk does not have it. +1. **Resolve the forge, then verify each PR independently before it lands.** GitHub CLI (`gh`) is the default. If `command -v origin` succeeds and Origin can resolve the repository, use `origin pr` for every PR operation. Otherwise stay on `gh` and record the fallback. Never require Graphite (`gt`). One verifier per PR, an isolated subagent (`isolated: true`), runs the change the way a user would with the matching control skill (such as the `browser` eval prelude or bash with a unique `name`, a `ready` check, and `proc://` state from omp's built-in tools), parent against head, and posts `PASS`, `ISSUES`, or `BLOCKED`, the three verdicts `skill://pstack-omp` and **swarm** use, on that PR. Notes ride on a `PASS` rather than forming a fourth verdict, so a passing run with observations is still a pass. Only a verdict from an agent that did not write the code counts, because an author checks what it meant to build. Green CI and an approving bot review are not verdicts, because neither runs the change as a user would. +2. **Land only the contiguous verified run rooted at the bottom.** Walk up from the lowest unmerged PR and stop at the first one whose verdict is not `PASS`. The PRs below it are the run, and the top one is the ceiling. A verified PR above an unverified one waits, because it cannot land without the code below it. One whose branch neither holds nor needs that code can move onto trunk and land on its own. It needs a fresh verdict first, because its old one ran with that code beneath it and trunk does not have it. 3. **Re-check that the verdicts still describe the code.** A verdict covers a patch, and a chain rebase moves every PR above it to a new SHA without failing any check. Compare `git patch-id` at the verdict SHA with the current head before trusting an older verdict. When the patches differ only in tests, docs, or lint config, a lane's result stands if what it ran builds the same at both SHAs apart from noise. Noise is a difference that also shows up between two builds of the verdict SHA, or an embedded commit SHA. Report each kind of noise with its files, so a reader can check the call. The change itself still gets fresh checks and a review, because the old verdict never saw it. A lane with no build output, such as a dev server, cannot show this, so rerun it, and re-verify anything else that drifted. 4. **Land bottom-up, one PR at a time.** Always merge the run's lowest unmerged PR, with `origin pr merge ` or `gh pr merge --squash`. On Origin the plain command also merges every PR below the one you name, and on GitHub a PR whose base is still its parent's branch merges into that branch, not trunk. Read the PR back for the result (`origin pr view ` or `gh pr view --json state,mergedAt,baseRefName`), because a merge command can report success when a merge queue only queued the PR, and a requested auto-merge is not a merge. A landed PR reads merged with trunk as its base, not its parent's branch. Arm `--auto` (Origin merge-when-ready or GitHub auto-merge) only on a PR whose verdict covers its current head, because it lands whatever head is ready. After a merge lands, retarget the PR whose base was the merged branch to trunk (`origin pr edit --base ` or `gh pr edit --base `), move its patch onto the merge result, and push with `--force-with-lease`. The verdict covers the patch, not its commits, so step 3's patch-id rule decides whether the verdict survives. Merge it once its checks pass and its verdict survives. 5. **Leave the stack alone while it lands.** Step 4's move of the next PR is the only change to a branch or base, because a merge lands whatever its branch and base hold at that moment, verdict or not. On GitHub, watch the run with the queued watcher from `playbooks/babysit.md` (`scripts/watch-pr/watch-pr --queued-stack --stack-prs `) under `/loop` until it reports `COMPLETE` at the ceiling. On Origin, re-read the current PR with `origin pr view --checks` each time `origin pr checks --watch` returns, until the ceiling reads merged. A base that moves to trunk when its parent merges is expected, not damage to repair. If a merge stalls, find out why before you change anything. diff --git a/plugins/pstack/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md b/plugins/pstack/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md index 06c7cfd..57ddd7b 100644 --- a/plugins/pstack/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +++ b/plugins/pstack/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md @@ -6,17 +6,9 @@ disable-model-invocation: true # Migrate Callers Then Delete Legacy APIs -When we decide a new API is the right design, migrate callers and remove the old API in the same refactor wave instead of preserving compatibility layers. - -**Rule:** -- Do not keep legacy API paths only because internal callers still exist -- Inventory callers, migrate them, and delete the old API immediately -- Treat temporary adapters as exceptional and time-boxed, not default architecture - Update tests to assert the new contract, and delete tests that only protect pre-refactor implementation details **When this applies:** - No external users depend on backward compatibility - The project can absorb coordinated breaking changes - The new API is part of a simplification or refactor initiative - -Keeping both old and new APIs creates dual-path complexity, slows cleanup, and makes the codebase feel append-only. diff --git a/plugins/pstack/skills/principle-type-system-discipline/SKILL.md b/plugins/pstack/skills/principle-type-system-discipline/SKILL.md index 4321b83..9fa08b0 100644 --- a/plugins/pstack/skills/principle-type-system-discipline/SKILL.md +++ b/plugins/pstack/skills/principle-type-system-discipline/SKILL.md @@ -1,6 +1,6 @@ --- name: principle-type-system-discipline -description: "Apply when designing types, reviewing a function signature, or writing code in any statically-typed language. Make illegal states unrepresentable, brand semantic primitives, parse external data at boundaries, refuse to lie to the compiler, exhaust variants, derive from authoritative schemas." +description: "Apply when designing types, reviewing a function signature, or writing code in any statically-typed language. Make illegal states unrepresentable, brand semantic primitives, parse external data at boundaries, exhaust variants, derive from authoritative schemas." disable-model-invocation: true --- @@ -8,11 +8,6 @@ disable-model-invocation: true Make the compiler reject impossible states, mismatched primitives, and unhandled variants. Prefer defining errors and special cases out of existence over proliferating handlers. For TypeScript syntax, see the `typescript-best-practices` skill. -- **Make illegal states unrepresentable.** Model variants as sum types (discriminated unions, enums with payloads, sealed classes), not as a bag of optional fields where contradictory combinations compile. `{ completed: boolean; completedAt?: Date }` admits `completed: true` with no date. Derive the boolean from `completedAt`, or model `{ kind: 'open' } | { kind: 'done'; at: Date }`. If you can write a comment explaining when a combination of fields is valid, split it into a sum type. -- **Types are constructions, not restrictions.** Build the type up from the values you want instead of carving them out of a looser type with checks. A non-empty list is a head plus a rest. A valid time range is a start plus a duration. Choose the shape that cannot build the illegal value and expose the interface callers need on top. - **Brand semantic primitives.** `UserId` and `OrderId` are strings underneath but should not be interchangeable. Give each its own type, such as a branded intersection in TypeScript or a newtype elsewhere. Validate once at creation, trust the type downstream. If two arguments share a primitive type but mean different things, brand them. - **External data is untyped until parsed.** RPC payloads, JSON, IPC messages, CLI args, config files, environment variables, database rows. Have a parse function at every boundary that turns unstructured input into the typed model. The **boundary-discipline** principle skill says where to put validation. -- **Don't lie to the type system.** Casts, unsafe coercions, and assertion functions that bypass the compiler are latent runtime crashes. If the compiler can't prove a fact, prove it (validate, narrow, refine the model) or accept that the cast is a hazard. Trace each `any`, `as`, or `assertNotNull` to the boundary and validate there. -- **Exhaustive matching is the compiler's job.** A match on a sum type must fail compilation when a new variant is added without handling, so the compiler tells the next agent where to add a case. Use your language's idiom: a `never`-typed binding in TypeScript, an unannotated `match` in Rust, `-Wincomplete-patterns` in Haskell, sealed-class match exhaustiveness in Kotlin. - **Derive types from authoritative schemas.** When a protocol buffer, OpenAPI spec, GraphQL schema, database migration, design-token file, or another file owns a shape, derive from it instead of hand-rolling a parallel type. See the **encode-lessons-in-structure** principle skill. -- **Strengthen a type only where partiality appears.** A runtime assertion, null check, or "this should never happen" throw marks the place a type is too weak. Push that check up into the type. Then stop. The type system's job is to track the cases each use site must handle, not to describe the data as precisely as possible. Prefer total functions. `sum` of an empty list is 0, so it takes the plain list. `head` of an empty list has no answer, so it demands the non-empty one. If nothing would otherwise panic, keep the plain type. diff --git a/plugins/pstack/skills/sharpen/SKILL.md b/plugins/pstack/skills/sharpen/SKILL.md index 4f9bb4a..5b4a7b1 100644 --- a/plugins/pstack/skills/sharpen/SKILL.md +++ b/plugins/pstack/skills/sharpen/SKILL.md @@ -1,7 +1,16 @@ --- name: sharpen -description: "Sharpen a task before starting: the expert approach, what the user is really after, and the right level of specification. Use for /sharpen." +description: "Make a task deeper, not bigger, then rewrite the prompt. Use for /sharpen." disable-model-invocation: true --- -Before starting, ask how a top expert would approach this task (their raw sources, their real methods, an outside check that the result is right), what the user is really after, and whether the task is over- or underspecified. Loosen constraints that don't serve the goal as readily as you add ones that do, and leave choices that are the user's to make open instead of deciding them. Fold in only what makes the work better, not bigger, but always keep one outside check that the result is right, without narrating the framing, then do the work. Don't hand back a rewritten prompt. +I want a deeper version of this task, not a bigger one. Before rewriting it, do four things: + +1. Say what a top professional in this field would do here that I'm not asking for. +2. Name the rawest material they would start from instead of what has already been digested for me. +3. Name the specific methods and tools they would use. +4. Say what, other than you or me, would show the result is right. + +Then rewrite my prompt to ask for exactly that. + +Leave choices that are mine to make open instead of deciding them. diff --git a/plugins/pstack/skills/statechart/SKILL.md b/plugins/pstack/skills/statechart/SKILL.md new file mode 100644 index 0000000..3cbeca9 --- /dev/null +++ b/plugins/pstack/skills/statechart/SKILL.md @@ -0,0 +1,36 @@ +--- +name: statechart +description: "Draw what an app's user sees as a Mermaid statechart from a TLA+ model. States are gray if they work, red if still wrong, and green if fixed. Each bug TLC finds is a numbered path. Use for /skill:statechart, \"show the PM the bug\", or to turn an agreed chart into a TLA+ skeleton." +disable-model-invocation: true +--- + +# Statechart + +The chart shows what an app's user sees. A PM reads it to decide whether that behavior is right. Label each state with what is on the screen, such as "Paying". Label each step with what the user does or sees happen, such as "tap Pay" or "the charge goes through". Keep variable and action names out of labels. The scripts call this user the reader, as in `readerActions` and reader steps. + +Run `node /scripts/statechart.mjs --help` for the commands and for how to read a chart. Work in a scratch folder outside the repo. The command writes its files next to its input. + +## Pick the direction + +Use `from-spec` when a TLA+ model exists or the code is concrete enough to model. Use `from-chart` when there is no model and the PM should agree on the behavior before anyone writes or changes the code. + +If the code already has a TLA+ spec, start from a copy of that spec. Add the variables the user sees, and keep the spec's existing invariants. To audit the code against that spec, make the copy model what the code does, and pass the spec unchanged as `--against`. In a new spec, tie every variable and action to code that a reviewer can open, and take its name from that code. Make each action one indivisible step in the code. + +## Spec to chart + +1. Choose the chart's states. When the PM cares about something the screen does not show, such as a second charge while the screen still says "Paying", name the state "Paying, charged twice". Start with few states, and split a state only when the split separates two bugs. +2. Write each rule the PM agrees to as one invariant, with the PM's sentence as its comment. For a rule that applies once everything settles, define `Settled == ~ENABLED System`, where `System` is the disjunction of the actions the user does not take. Report each broken rule as a bug. Never weaken a rule to make the chart pass. +3. Run `from-spec`, then fill in the mapping file it writes. Put the labels in `state` and `step`, and list the user's actions in `readerActions` by action name. When two bugs end on the same screen through different code paths, add `cause` and make it name the code path. To color the states by what the PM cares about, such as "Toast shown", return that group from `group`. The legend names the groups, and states that break a rule still get a red border. +4. If there is an open fix, model it from the code and pass that model as `--fixed`. +5. Render each chart with `--png` and look at every PNG. Check that each numbered path reads in order. If a label reads like code, fix `state` or `step` and run the command again. +6. Replay each bug's story against the real code, as a test or a live repro, one step per model action. If a bug does not reproduce, assume the model is wrong until you show otherwise. Fix the model, not the rule. + +## Chart to spec + +1. Draw the chart the PM agrees to. Use one region for each thing that changes independently, such as the connection and the sync badge. Add one `%% never` line per rule, with the PM's sentence. Draw "the app closes" as an edge to the state the user sees next. +2. Run `from-chart`. If a rule breaks, the agreed chart has a design bug. Take the bug to the PM. +3. Add the real code's guards and variables to the spec that `from-chart` wrote, and run `from-chart` again. For each difference it prints, ask the PM whether the code is wrong or the chart needs a correction. + +## Hand off + +Give the PM the PNG and the Mermaid source of each chart. Add one line per bug with the rule it breaks, its steps as the chart numbers them, whether it reproduced, and whether the open fix fixes it. Take every count you quote from `summary.json`. Attach `stories.md` and the command that regenerates everything. Say what the model leaves out. diff --git a/plugins/pstack/skills/statechart/scripts/chart-to-tla.mjs b/plugins/pstack/skills/statechart/scripts/chart-to-tla.mjs new file mode 100644 index 0000000..7dfa337 --- /dev/null +++ b/plugins/pstack/skills/statechart/scripts/chart-to-tla.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { parseMermaid } from "./lib.mjs"; + +const USAGE = "Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"; +const HELP = `${USAGE} + +Writes a TLA+ spec, a TLC config, and a reader mapping from a Mermaid stateDiagram-v2. +statechart.mjs from-chart runs it, then checks each INVARIANT in NAME.cfg alone. + +Each region becomes one variable whose values are the region's states. Each transition label +becomes one action. Regions that share a label move together: every region that can take it does, +and the others stay. A "%% when" line after a transition is its guard: the region can take that +transition only while the other regions are in, or with "not" are not in, the states it names. An +unlabeled transition is its own action. Each +"%% never NAME: A & B -- sentence" line becomes the invariant NAME == ~(A /\\ B). A name in a +never line is a state, or a composite that stands for every state inside it. + + --module NAME The TLA+ module name. Required. + --dir OUT The folder for the files. Default: the chart's folder. + -h, --help Print this help, and exit 0. + +Writes NAME.tla, NAME.cfg, and NAME.map.mjs into OUT, and replaces files with those names. +NAME.cfg has SPECIFICATION Spec, INVARIANT TypeOK, and one INVARIANT per never line. +NAME.map.mjs gives tlc-to-chart.mjs the reader's words: the region states joined by " | ". + +Exit codes + 0 It wrote the files. + 1 It cannot read the chart. The error says why. + 2 A bad argument.`; +const more = "Run node chart-to-tla.mjs --help for the flags."; + +const args = {}; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + if (a === "-h" || a === "--help") { + console.log(HELP); + process.exit(0); + } else if (a === "--module") args.module = process.argv[++i]; + else if (a === "--dir") args.dir = process.argv[++i]; + else if (!a.startsWith("-") && !args.chart) args.chart = a; + else { + console.error(`unknown argument ${a}. ${more}`); + process.exit(2); + } +} +if (!args.chart || !/^[A-Za-z][A-Za-z0-9_]*$/.test(args.module ?? "")) { + console.error(`${USAGE}\n${more}`); + process.exit(2); +} +const dir = args.dir ?? path.dirname(args.chart); +const { regions, nevers } = parseMermaid(fs.readFileSync(args.chart, "utf8")); + +const RESERVED = new Set(["vars", "Init", "Next", "Spec", "TypeOK", "Settled"]); +const taken = new Set(RESERVED); +function ident(words, lower) { + const parts = words + .normalize("NFKD") + .replace(/[^A-Za-z0-9]+/g, " ") + .trim() + .split(" ") + .filter(Boolean); + let id = + parts + .map((p, i) => (lower && i === 0 ? p[0].toLowerCase() : p[0].toUpperCase()) + p.slice(1)) + .join("") || "Step"; + if (/^[0-9]/.test(id)) id = `${lower ? "r" : "S"}${id}`; + let unique = id; + for (let n = 2; taken.has(unique); n++) unique = `${id}${n}`; + taken.add(unique); + return unique; +} +const str = (s) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +const set = (xs) => `{${[...xs].map(str).join(", ")}}`; + +const vars = Object.entries(regions).map(([name, r]) => { + if (r.states.size === 0) throw new Error(`region ${name} has no states`); + if (r.initial.size === 0) throw new Error(`region ${name} has no [*] --> start`); + return { name, v: ident(name, true), ...r }; +}); + +function guardOf(g) { + if (!g) return null; + const parts = Object.entries(g.states).map(([name, states]) => { + const { v } = vars.find((x) => x.name === name); + return states.length === 1 ? `${v} = ${str(states[0])}` : `${v} \\in ${set(states)}`; + }); + return g.not ? `~(${parts.join(" \\/ ")})` : parts.join(" /\\ "); +} + +const actions = []; +const byLabel = new Map(); +for (const [ri, r] of vars.entries()) { + for (const t of r.transitions) { + let a = t.label ? byLabel.get(t.label) : null; + if (!a) { + a = { label: t.label, moves: new Map() }; + actions.push(a); + if (t.label) byLabel.set(t.label, a); + } + if (!a.moves.has(ri)) a.moves.set(ri, []); + const moves = a.moves.get(ri); + const when = guardOf(t.guard); + if (!moves.some((m) => m.from === t.from && m.to === t.to && m.when === when)) + moves.push({ from: t.from, to: t.to, when }); + } +} +for (const a of actions) { + const only = a.label ? null : [...a.moves.values()][0][0]; + a.name = ident(a.label || `${only.from} to ${only.to}`, false); +} + +const out = []; +const conjuncts = (lines) => lines.map((l) => ` /\\ ${l}`); +out.push(`---- MODULE ${args.module} ----`); +out.push(`\\* Generated by chart-to-tla.mjs from ${path.basename(args.chart)}.`); +out.push("\\* Each region is one variable and each transition label is one action."); +out.push("\\* Once you add guards and variables from the real code, this file is the source."); +out.push( + `\\* statechart.mjs from-chart ${path.basename(args.chart)} keeps it, checks it, and compares it with the chart.`, +); +out.push(""); +out.push(`VARIABLES ${vars.map((x) => x.v).join(", ")}`); +out.push(`vars == <<${vars.map((x) => x.v).join(", ")}>>`); +out.push(""); +for (const x of vars) + out.push(`\\* Region ${x.name}`, `${x.v}States == ${set([...x.states].sort())}`); +out.push(""); +out.push("TypeOK ==", ...conjuncts(vars.map((x) => `${x.v} \\in ${x.v}States`))); +out.push(""); +out.push( + "Init ==", + ...conjuncts( + vars.map((x) => + x.initial.size === 1 ? `${x.v} = ${str([...x.initial][0])}` : `${x.v} \\in ${set(x.initial)}`, + ), + ), +); +const enabled = (v, m) => `${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""}`; +function whetherTakes(a, ri) { + const { v } = vars[ri]; + const moves = a.moves.get(ri); + if (moves.every((m) => !m.when)) { + const from = set(new Set(moves.map((m) => m.from))); + return { can: `${v} \\in ${from}`, cannot: `${v} \\notin ${from}` }; + } + const any = moves.map((m) => `(${enabled(v, m)})`).join(" \\/ "); + return { can: any, cannot: `~(${any})` }; +} +for (const a of actions) { + out.push(""); + if (a.label) out.push(`\\* ${a.label}`); + out.push(`${a.name} ==`); + const moving = [...a.moves.keys()]; + const shared = moving.length > 1; + if (shared) out.push(` /\\ (${moving.map((ri) => whetherTakes(a, ri).can).join(" \\/ ")})`); + for (const ri of moving) { + const { v } = vars[ri]; + const ds = a.moves.get(ri).map((m) => `(${enabled(v, m)} /\\ ${v}' = ${str(m.to)})`); + if (shared) ds.push(`(${whetherTakes(a, ri).cannot} /\\ UNCHANGED ${v})`); + out.push(` /\\ \\/ ${ds[0]}`); + for (const d of ds.slice(1)) out.push(` \\/ ${d}`); + } + const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v); + if (still.length) out.push(` /\\ UNCHANGED <<${still.join(", ")}>>`); +} +out.push(""); +out.push( + actions.length ? `Next == ${actions.map((a) => a.name).join(" \\/ ")}` : "Next == UNCHANGED vars", +); +out.push(""); +out.push("Spec == Init /\\ [][Next]_vars"); + +function resolve(item) { + const hits = []; + for (const [ri, x] of vars.entries()) { + const inside = x.states.has(item) + ? [item] + : [...x.states].filter((s) => s.startsWith(`${item}.`)); + if (inside.length) hits.push({ ri, inside }); + } + if (hits.length === 0) + throw new Error(`never line names "${item}", which is not a state in the chart`); + if (hits.length > 1) + throw new Error( + `never line names "${item}", which is a state in regions ${hits.map((h) => vars[h.ri].name).join(" and ")}; rename one`, + ); + return hits[0]; +} +const invariants = []; +for (const n of nevers) { + if (taken.has(n.name)) throw new Error(`never name ${n.name} is already used in the spec`); + taken.add(n.name); + const parts = n.states.map(resolve); + const regionsUsed = new Set(parts.map((p) => p.ri)); + if (regionsUsed.size !== parts.length) + throw new Error( + `never ${n.name} names two states of one region; one region is in one state at a time`, + ); + const cond = parts.map((p) => + p.inside.length === 1 + ? `${vars[p.ri].v} = ${str(p.inside[0])}` + : `${vars[p.ri].v} \\in ${set(p.inside)}`, + ); + out.push(""); + if (n.sentence) out.push(`\\* ${n.sentence}`); + out.push(`${n.name} == ~(${cond.join(" /\\ ")})`); + invariants.push(n.name); +} +out.push("", "====", ""); + +const rules = ["TypeOK", ...invariants]; +const cfg = ["SPECIFICATION Spec", ...rules.map((r) => `INVARIANT ${r}`), ""]; + +const labels = Object.fromEntries(actions.map((a) => [a.name, a.label])); +const map = [ + `// Generated by chart-to-tla.mjs from ${path.basename(args.chart)}. Reader states are the chart's own states.`, + `export const regions = ${JSON.stringify(vars.map((x) => x.v))};`, + `const LABELS = ${JSON.stringify(labels, null, "\t")};`, + "", + "// The actions the reader takes. facts.settled needs them. Add cause() and detail() as the spec grows.", + "export const readerActions = [];", + 'export const state = (v) => regions.map((r) => v[r]).join(" | ");', + "export const step = (action) => LABELS[action] ?? action;", + "", +]; + +fs.mkdirSync(dir, { recursive: true }); +fs.writeFileSync(path.join(dir, `${args.module}.tla`), out.join("\n")); +fs.writeFileSync(path.join(dir, `${args.module}.cfg`), cfg.join("\n")); +fs.writeFileSync(path.join(dir, `${args.module}.map.mjs`), map.join("\n")); +console.error( + `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), rules ${rules.join(" ")} -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`, +); diff --git a/plugins/pstack/skills/statechart/scripts/compare-charts.mjs b/plugins/pstack/skills/statechart/scripts/compare-charts.mjs new file mode 100644 index 0000000..587125a --- /dev/null +++ b/plugins/pstack/skills/statechart/scripts/compare-charts.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import process from "node:process"; +import { FULL_LABELS, LEGEND_ID, compareCharts, parseMermaid } from "./lib.mjs"; + +const USAGE = "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"; +const HELP = `${USAGE} + +Compares two Mermaid stateDiagram-v2 charts: reachable states, start states, and labeled +transitions. statechart.mjs from-chart runs it to check that the spec still draws the chart that +the reader agreed to. + +EXPECTED is the agreed chart. ACTUAL is what tlc-to-chart.mjs drew from the spec in the customer +view with --highlight 0 --full-labels --flat, without --cover, --fixed-dot, or --against-dot. Each +chart compares as the +machine that its steps reach from its start states. A chart with parallel regions compares as its +combined machine, so a guard that blocks a step in only some combinations of regions shows as a +difference. A "%% when ID, ID" or "%% when not ID, ID" line right after a transition is its guard: +the step happens only while another region is in, or not in, one of those states. The label ends +with the guard's words in [ ], which are not part of the label. A combined state is the region +states joined by " | " in region order, which is what chart-to-tla.mjs's mapping writes. A step that does not change the state is not compared, because +tlc-to-chart.mjs does not draw it. An edge label "a or b" counts as two labels. An unlabeled edge +counts only when no labeled edge joins the same two states, because tlc-to-chart.mjs draws only +the labels. + + -h, --help Print this help, and exit 0. + +Prints each difference and then "N difference(s)", or prints "same". Writes no file. + +Exit codes + 0 The charts are the same. + 1 The charts differ. + 2 A bad argument, a chart that it cannot read, or an ACTUAL chart that is not drawn for + comparison.`; +const more = "Run node compare-charts.mjs --help for the flags."; + +const files = []; +for (const a of process.argv.slice(2)) { + if (a === "-h" || a === "--help") { + console.log(HELP); + process.exit(0); + } else if (a.startsWith("-") || files.length === 2) { + console.error(`unknown argument ${a}. ${more}`); + process.exit(2); + } else files.push(a); +} +if (files.length < 2) { + console.error(`${USAGE}\n${more}`); + process.exit(2); +} +const [expectedFile, actualFile] = files; + +const orExit = (file, f) => { + try { + return f(); + } catch (e) { + console.error(`${file}: ${e.message}`); + process.exit(2); + } +}; +const [expectedText, actualText] = files.map((f) => orExit(f, () => fs.readFileSync(f, "utf8"))); +const has = (re) => re.test(actualText); +const textRefusals = [ + [ + has(/^\s*%% second model/m), + "it has a second model; draw ACTUAL without --fixed-dot or --against-dot", + ], + [ + has(new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+(?!${LEGEND_ID}\\b)\\w+\\s*\\{`, "m")), + "it has boxes; draw ACTUAL with --flat", + ], + [ + has(new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+${LEGEND_ID}\\s*\\{`, "m")) && + !actualText.includes(FULL_LABELS), + "edge labels write shared words once; draw ACTUAL with --full-labels", + ], +]; +for (const [bad, why] of textRefusals) { + if (bad) { + console.error(`${actualFile}: ${why}`); + process.exit(2); + } +} +const expectedRegions = orExit(expectedFile, () => parseMermaid(expectedText).regions); +const actualRegions = orExit(actualFile, () => parseMermaid(actualText).regions); +const numbered = Object.values(actualRegions) + .flatMap((r) => r.transitions) + .some((t) => /^\d+(, \d+)*\. /.test(t.label)); +if (numbered) { + console.error( + `${actualFile}: an edge label is a numbered path step; draw ACTUAL with --highlight 0`, + ); + process.exit(2); +} + +const rows = compareCharts(expectedRegions, actualRegions); +let differences = 0; +for (const [title, xs] of rows) { + console.log(`${title}:`); + for (const x of xs) console.log(` ${x}`); + differences += xs.length; +} +console.log(differences === 0 ? "same" : `${differences} difference(s)`); +process.exit(differences === 0 ? 0 : 1); diff --git a/plugins/pstack/skills/statechart/scripts/lib.mjs b/plugins/pstack/skills/statechart/scripts/lib.mjs new file mode 100644 index 0000000..cf72d5c --- /dev/null +++ b/plugins/pstack/skills/statechart/scripts/lib.mjs @@ -0,0 +1,723 @@ +import fs from "node:fs"; +import process from "node:process"; + +export function findTlc() { + const { TLA2TOOLS, JAVA } = process.env; + if (!TLA2TOOLS) + throw new Error( + "Cannot find TLC. Download tla2tools.jar from a TLA+ release:\n https://github.com/tlaplus/tlaplus/releases\nThen set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later).", + ); + if (!fs.existsSync(TLA2TOOLS)) throw new Error(`TLA2TOOLS is ${TLA2TOOLS}, which does not exist.`); + return { jar: TLA2TOOLS, java: JAVA || "java" }; +} + +export function parseValue(text) { + const src = text.trim(); + let i = 0; + const ws = () => { + while (i < src.length && /\s/.test(src[i])) i++; + }; + const peek = (s) => src.startsWith(s, i); + const eat = (s) => { + ws(); + if (!peek(s)) throw new Error(`expected ${s} at ${i} in ${src}`); + i += s.length; + }; + const list = (close, item) => { + const out = []; + ws(); + if (peek(close)) { + i += close.length; + return out; + } + for (;;) { + out.push(item()); + ws(); + if (peek(",")) { + i++; + continue; + } + eat(close); + return out; + } + }; + const value = () => { + ws(); + if (peek("<<")) { + i += 2; + return list(">>", value); + } + if (peek("{")) { + i++; + return list("}", value); + } + if (peek("[")) { + i++; + const pairs = list("]", () => { + ws(); + const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); + if (!m) throw new Error(`bad record field at ${i}`); + i += m[0].length; + eat("|->"); + return [m[0], value()]; + }); + return Object.fromEntries(pairs); + } + if (peek("(")) { + i++; + const fn = {}; + for (;;) { + const k = value(); + eat(":>"); + fn[typeof k === "string" ? k : JSON.stringify(k)] = value(); + ws(); + if (peek("@@")) { + i += 2; + continue; + } + eat(")"); + return fn; + } + } + if (peek('"')) { + i++; + let s = ""; + while (i < src.length && src[i] !== '"') { + if (src[i] === "\\") i++; + s += src[i++]; + } + i++; + return s; + } + const m = /^-?\d+|^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); + if (!m) throw new Error(`bad value at ${i} in ${src}`); + i += m[0].length; + if (m[0] === "TRUE") return true; + if (m[0] === "FALSE") return false; + return /^-?\d+$/.test(m[0]) ? Number(m[0]) : m[0]; + }; + try { + const v = value(); + ws(); + return i === src.length ? v : src; + } catch { + return src; + } +} + +// One state as TLC prints it: "/\ x = 1" lines, or "x = 1" for one variable. +function parseState(text) { + const lines = text.split("\n").filter((l) => l.trim() !== ""); + const conjuncts = []; + for (const line of lines) { + if (/^\s*\/\\ /.test(line) || conjuncts.length === 0) + conjuncts.push(line.replace(/^\s*\/\\ /, "")); + else conjuncts[conjuncts.length - 1] += ` ${line.trim()}`; + } + const vars = {}; + for (const c of conjuncts) { + const eq = c.indexOf(" = "); + if (eq < 0) continue; + vars[c.slice(0, eq).trim()] = parseValue(c.slice(eq + 3)); + } + return vars; +} + +export const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +const canon = (v) => + Array.isArray(v) + ? v.map(canon) + : isRecord(v) + ? Object.fromEntries( + Object.keys(v) + .sort() + .map((k) => [k, canon(v[k])]), + ) + : v; +export const stateKey = (vars) => JSON.stringify(canon(vars)); + +export function ruleSentences(tla) { + const out = new Map(); + const lines = tla.split("\n"); + lines.forEach((line, i) => { + const name = /^([A-Za-z_]\w*)\s*==/.exec(line)?.[1]; + if (!name) return; + const said = []; + for (let j = i - 1; j >= 0 && /^\s*\\\*/.test(lines[j]); j--) + said.unshift(lines[j].replace(/^\s*\\\*\s?/, "").trim()); + const sentence = said.filter(Boolean).join(" "); + if (sentence) out.set(name, sentence); + }); + return out; +} + +function unescapeDot(s) { + let out = ""; + for (let i = 0; i < s.length; i++) { + if (s[i] === "\\" && i + 1 < s.length) { + const n = s[++i]; + out += n === "n" ? "\n" : n; + } else out += s[i]; + } + return out; +} + +export function parseDot(text) { + const nodes = new Map(); + const edges = []; + const loops = []; + const seen = new Set(); + const LABEL = '"((?:[^"\\\\]|\\\\.)*)"'; + const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`); + const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`); + for (const line of text.split("\n")) { + let m = edgeRe.exec(line); + if (m) { + const e = { from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" }; + const id = `${e.from} ${e.to} ${e.action}`; + if (seen.has(id)) continue; + seen.add(id); + (e.from === e.to ? loops : edges).push(e); + continue; + } + m = nodeRe.exec(line); + if (m) + nodes.set(m[1], { + id: m[1], + vars: parseState(unescapeDot(m[2])), + init: /style\s*=\s*filled/.test(m[3]), + }); + } + if (nodes.size === 0) + throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels "); + const missing = [...edges, ...loops].find((e) => !nodes.has(e.from) || !nodes.has(e.to)); + if (missing) + throw new Error( + `transition ${missing.from} -> ${missing.to} names a state the file does not define`, + ); + if (edges.length > 0 && [...edges, ...loops].every((e) => !e.action)) + throw new Error( + "the graph has no action names; dump it with TLC -dump dot,actionlabels ", + ); + return { nodes, edges, loops }; +} + +export function parseTraces(text) { + const traces = []; + let invariant = null; + let current = null; + const lines = text.split("\n"); + const stateAt = (i) => { + const body = []; + while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]); + return [parseState(body.join("\n")), i]; + }; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const inv = /^Error: Invariant (\S+) is violated( by the initial state:)?/.exec(line); + if (inv) { + invariant = inv[1]; + if (inv[2]) { + const [vars, end] = stateAt(i); + traces.push({ invariant, steps: [{ action: null, vars }] }); + i = end; + } + continue; + } + if (line.startsWith("Error: ") && !line.startsWith("Error: The behavior up to this point is")) + invariant = null; + const head = /^State (\d+): <([^\s>(]+)/.exec(line); + if (!head) continue; + const [vars, end] = stateAt(i); + i = end; + if (head[1] === "1") { + current = { invariant, steps: [] }; + traces.push(current); + } + current?.steps.push({ action: head[2] === "Initial" ? null : head[2], vars }); + } + return traces; +} + +// With -continue, TLC prints "Model checking completed" even after broken invariants, and not +// when it stopped early. +export function checkTlcOutput(text, file) { + const other = text + .split("\n") + .find( + (l) => + l.startsWith("Error: ") && + !/^Error: (Invariant \S+ is violated|The behavior up to this point is)/.test(l), + ); + if (other) + throw new Error( + `${file}: TLC stopped on an error that is not a broken rule: ${other.slice(7)}`, + ); + if (!/^Model checking completed\./m.test(text)) + throw new Error( + `${file}: TLC did not finish, so its counterexamples are not all there; read the file`, + ); +} + +export function wrap(text, width = 26) { + const words = String(text).split(/\s+/); + const rows = []; + let row = ""; + for (const w of words) { + if (row && (row + " " + w).length > width) { + rows.push(row); + row = w; + } else row = row ? `${row} ${w}` : w; + } + if (row) rows.push(row); + return rows.join("
"); +} + +const OPENS_A_PHRASE = new Set( + "a an the your my our their its this that to of in on at for with from by and or".split(" "), +); +export function joinLabels(labels) { + if (labels.length < 2) return labels.join(" or "); + const words = labels.map((l) => String(l).split(" ")); + let n = 0; + while (words.every((w) => n < w.length - 1 && w[n] === words[0][n])) n++; + while (n > 0 && OPENS_A_PHRASE.has(words[0][n - 1])) n--; + if (n < 2) return labels.join(" or "); + const rest = words.map((w) => w.slice(n).join(" ")); + const list = + rest.length === 2 ? rest.join(" or ") : `${rest.slice(0, -1).join(", ")}, or ${rest.at(-1)}`; + return `${words[0].slice(0, n).join(" ")} ${list}`; +} + +const unwrap = (s) => + s + .replace(/|#160;/g, " ") + .replace(/#(quot|34);/g, '"') + .replace(/\s+/g, " ") + .trim(); + +// Mermaid ends a quoted state caption at the first ", so a caption writes " as #quot;. +export const quote = (s) => String(s).replaceAll('"', "#quot;"); + +export const LEGEND_ID = "legend"; +export const FULL_LABELS = "%% labels in full"; + +export function parseMermaid(text) { + const names = new Map(); + const nevers = []; + let last = null; + const newPart = () => ({ children: [], transitions: [] }); + const root = { name: "", parts: [newPart()] }; + const stack = [root]; + const part = () => stack[stack.length - 1].parts.at(-1); + const display = (id) => names.get(id) ?? id; + const implicit = new Map(); + const declare = (id) => { + const prev = implicit.get(id); + if (prev) + prev.children.splice( + prev.children.findIndex((c) => c.name === id), + 1, + ); + implicit.set(id, null); + }; + const mention = (id) => { + if (id === "[*]" || implicit.has(id)) return; + part().children.push({ name: id, leaf: true }); + implicit.set(id, part()); + }; + const body = text.replace(/^\s*---[ \t]*\r?\n[^]*?\r?\n---[ \t]*(\r?\n|$)/, ""); + for (const raw of body.split("\n")) { + const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1"); + const when = /^%%\s*when\s+(not\s+)?(.+)$/.exec(line); + if (when) { + if (!last) throw new Error(`a %% when line must come right after a transition: ${line}`); + if (!last.words) + throw new Error( + `the guarded step "${last.label}" must end its label with the guard's words in [ ]`, + ); + last.when = { not: Boolean(when[1]), ids: when[2].split(",").map((s) => s.trim()) }; + last = null; + continue; + } + last = null; + const never = /^%%\s*never\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)(?:\s+--\s+(.*))?$/.exec(line); + if (never) { + nevers.push({ + name: never[1], + states: never[2].split("&").map((s) => s.trim()), + sentence: never[3] ?? "", + }); + continue; + } + if ( + !line || + line.startsWith("%%") || + /^(stateDiagram|direction|classDef|class |note)/.test(line) + ) + continue; + let m = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); + if (!m) { + const plain = /^state\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); + if (plain) m = [line, null, plain[1], plain[2]]; + } + if (m) { + if (m[1] !== null && !names.has(m[2])) names.set(m[2], unwrap(m[1])); + declare(m[2]); + if (m[3]) { + const node = { name: m[2], parts: [newPart()] }; + part().children.push(node); + stack.push(node); + } else part().children.push({ name: m[2], leaf: true }); + continue; + } + if (line === "}") { + if (stack.length === 1) throw new Error("unmatched } in the chart"); + stack.pop(); + continue; + } + if (line === "--") { + stack[stack.length - 1].parts.push(newPart()); + continue; + } + m = /^(\[\*\]|[A-Za-z_]\w*)\s*-->\s*(\[\*\]|[A-Za-z_]\w*)(?:\s*:\s*(.*))?$/.exec(line); + if (m) { + if (m[2] === "[*]" && m[1] !== "[*]") + throw new Error( + `cannot read final state in "${line}"; draw the step to the state the reader sees next`, + ); + mention(m[1]); + mention(m[2]); + const said = m[3] ? unwrap(m[3]) : ""; + const label = said.replace(/\s*\[[^\]]*\]/g, ""); + last = { from: m[1], to: m[2], label, words: label !== said }; + part().transitions.push(last); + continue; + } + m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line); + if (m) { + if (!names.has(m[1])) names.set(m[1], unwrap(m[2])); + if (implicit.get(m[1]) === null) continue; + declare(m[1]); + part().children.push({ name: m[1], leaf: true }); + continue; + } + throw new Error(`cannot read chart line: ${line}`); + } + if (stack.length !== 1) throw new Error("the chart has an unclosed {"); + if (root.parts.length > 1) + throw new Error( + "the chart has -- outside a composite; to make parallel regions, split one top-level composite with --", + ); + + // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. + function region(p) { + const out = { states: new Set(), initial: new Set(), transitions: [] }; + const info = new Map(); + const declared = new Set(); + (function collect(q) { + for (const c of q.children) { + if (c.name === LEGEND_ID) continue; + declared.add(c.name); + if (!c.leaf) for (const sub of c.parts) collect(sub); + } + })(p); + const transitions = []; + const lookup = (id) => { + const found = info.get(id); + if (!found) throw new Error(`transition names ${id}, which is not in this region`); + return found; + }; + function states(q, prefix, depth) { + const local = q.children.filter((c) => c.name !== LEGEND_ID); + for (const t of q.transitions) { + for (const end of [t.from, t.to]) { + if (end === "[*]" || declared.has(end)) continue; + declared.add(end); + local.push({ name: end, leaf: true }); + } + } + const all = []; + for (const c of local) { + if (info.has(c.name)) { + if (local.indexOf(c) !== local.findIndex((x) => x.name === c.name)) continue; + throw new Error(`state id ${c.name} is declared in two places`); + } + const full = `${prefix}${display(c.name)}`; + if (c.leaf) { + if (out.states.has(full)) + throw new Error(`two states have the caption "${full}"; give each its own caption`); + out.states.add(full); + info.set(c.name, { all: [full], initial: [full], depth }); + all.push(full); + continue; + } + if (c.parts.length > 1) + throw new Error( + `${display(c.name)} splits into regions below the top level; make each region a top-level region`, + ); + const inner = states(c.parts[0], `${full}.`, depth + 1); + const initial = c.parts[0].transitions + .filter((t) => t.from === "[*]") + .flatMap((t) => lookup(t.to).initial); + if (initial.length === 0) + throw new Error( + `${display(c.name)} has no [*] --> start, so a transition into it has nowhere to land`, + ); + info.set(c.name, { all: inner, initial, depth }); + all.push(...inner); + } + for (const t of q.transitions) if (t.from !== "[*]" && t.to !== "[*]") transitions.push(t); + return all; + } + states(p, "", 0); + for (const t of p.transitions) + if (t.from === "[*]") for (const s of lookup(t.to).initial) out.initial.add(s); + transitions.sort((a, b) => lookup(b.from).depth - lookup(a.from).depth); + const handled = new Set(); + let pending = []; + let depth = null; + for (const t of transitions) { + const from = lookup(t.from); + if (from.depth !== depth) { + for (const h of pending) handled.add(h); + pending = []; + depth = from.depth; + } + for (const f of from.all) { + const key = `${f}\u0000${t.label}`; + if (t.label && handled.has(key)) continue; + for (const to of lookup(t.to).initial) + out.transitions.push({ + from: f, + to, + label: t.label, + ...(t.when ? { when: t.when } : {}), + }); + if (t.label) pending.push(key); + } + } + return [out, info]; + } + + const owners = new Map(); + const regions = {}; + const top = root.parts[0]; + const machine = top.children.filter((c) => c.name !== LEGEND_ID); + const parallel = + machine.length === 1 && + !machine[0].leaf && + machine[0].parts.length > 1 && + top.transitions.every((t) => t.from === "[*]"); + if (parallel) { + for (const p of machine[0].parts) { + const [only] = p.children; + const holder = + p.children.length === 1 && + !only.leaf && + p.transitions.every((t) => t.from === "[*]" && t.to === only.name) + ? only + : null; + if (holder && holder.parts.length > 1) + throw new Error(`${display(holder.name)} splits into regions below the top level`); + const name = holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`; + const [r, ids] = region(holder ? holder.parts[0] : p); + regions[name] = r; + for (const [id, { all }] of ids) owners.set(id, { name, all }); + } + } else { + const [r, ids] = region(top); + regions.Main = r; + for (const [id, { all }] of ids) owners.set(id, { name: "Main", all }); + } + for (const [name, r] of Object.entries(regions)) + for (const t of r.transitions) { + if (!t.when) continue; + if (!parallel) + throw new Error("a %% when line guards a step on other regions, and the chart has none"); + const states = {}; + for (const id of t.when.ids) { + const at = owners.get(id); + if (!at) + throw new Error(`a %% when line names ${id}, which is not a state id in the chart`); + if (at.name === name) + throw new Error( + `a %% when line names ${display(id)}, which is in the step's own region; a guard reads other regions`, + ); + states[at.name] = [...new Set([...(states[at.name] ?? []), ...at.all])].sort(); + } + t.guard = { not: t.when.not, states }; + delete t.when; + } + return { regions, nevers }; +} + +function guardHolds(guard, at) { + if (!guard) return true; + const hits = Object.entries(guard.states).map(([r, states]) => states.includes(at[r])); + return guard.not ? !hits.some(Boolean) : hits.every(Boolean); +} + +export function productOf(regions) { + const names = Object.keys(regions); + const rs = Object.values(regions); + const join = (parts) => parts.join(" | "); + const byLabel = new Map(); + const unlabeled = []; + rs.forEach((r, ri) => { + for (const t of r.transitions) { + if (!t.label) { + unlabeled.push({ ri, from: t.from, to: t.to, guard: t.guard }); + continue; + } + if (!byLabel.has(t.label)) + byLabel.set( + t.label, + rs.map(() => new Map()), + ); + const moves = byLabel.get(t.label)[ri]; + if (!moves.has(t.from)) moves.set(t.from, []); + moves.get(t.from).push({ to: t.to, guard: t.guard }); + } + }); + let starts = [[]]; + for (const r of rs) starts = starts.flatMap((p) => [...r.initial].map((s) => [...p, s])); + const out = { states: new Set(), initial: new Set(starts.map(join)), transitions: [] }; + const seen = new Set(starts.map(join)); + const queue = [...starts]; + const step = (from, to, label) => { + const [a, b] = [join(from), join(to)]; + if (a === b) return; + out.transitions.push({ from: a, to: b, label }); + if (!seen.has(b)) { + seen.add(b); + queue.push(to); + } + }; + while (queue.length) { + const cur = queue.shift(); + out.states.add(join(cur)); + const at = Object.fromEntries(names.map((n, ri) => [n, cur[ri]])); + for (const u of unlabeled) { + if (cur[u.ri] !== u.from || !guardHolds(u.guard, at)) continue; + const next = [...cur]; + next[u.ri] = u.to; + step(cur, next, ""); + } + for (const [label, perRegion] of byLabel) { + const open = perRegion.map((moves, ri) => + (moves.get(cur[ri]) ?? []).filter((m) => guardHolds(m.guard, at)).map((m) => m.to), + ); + if (open.every((tos) => tos.length === 0)) continue; + let nexts = [[]]; + open.forEach((tos, ri) => { + const options = tos.length ? [...new Set(tos)] : [cur[ri]]; + nexts = nexts.flatMap((p) => options.map((s) => [...p, s])); + }); + for (const next of nexts) step(cur, next, label); + } + } + return out; +} + +export function compareCharts(expectedRegions, actualRegions) { + const expected = productOf(expectedRegions); + const actual = productOf(actualRegions); + const edgeSet = (m) => { + const labeled = new Set(); + const out = new Set(); + for (const t of m.transitions) { + if (!t.label) continue; + labeled.add(`${t.from} --> ${t.to}`); + for (const l of t.label.split(" or ")) out.add(`${t.from} --> ${t.to} : ${l}`); + } + for (const t of m.transitions) + if (!t.label && !labeled.has(`${t.from} --> ${t.to}`)) out.add(`${t.from} --> ${t.to}`); + return out; + }; + const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort(); + const unreached = []; + const regionEntries = Object.entries(expectedRegions); + if (regionEntries.length === 1) { + for (const s of regionEntries[0][1].states) if (!expected.states.has(s)) unreached.push(s); + } else { + regionEntries.forEach(([name, r], ri) => { + const seen = new Set([...expected.states].map((s) => s.split(" | ")[ri])); + for (const s of r.states) if (!seen.has(s)) unreached.push(`${name}: ${s}`); + }); + } + return [ + ["states the agreed chart's own steps never reach", unreached.sort()], + [ + "states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them)", + minus(expected.states, actual.states), + ], + ["states only in ACTUAL", minus(actual.states, expected.states)], + ["start states only in EXPECTED", minus(expected.initial, actual.initial)], + ["start states only in ACTUAL", minus(actual.initial, expected.initial)], + [ + "transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step)", + minus(edgeSet(expected), edgeSet(actual)), + ], + ["transitions only in ACTUAL", minus(edgeSet(actual), edgeSet(expected))], + ].filter(([, xs]) => xs.length > 0); +} + +export const STATUS_CLASSES = [ + "classDef works fill:#f4f6f7,stroke:#566573,color:#1b2631", + "classDef wrong fill:#fdedec,stroke:#c0392b,color:#7b241c", + "classDef fixed fill:#e9f7ef,stroke:#1e8449,color:#145a32", + "classDef worksPath fill:#f4f6f7,stroke:#1f3a93,stroke-width:4px,color:#1b2631", + "classDef wrongPath fill:#fdedec,stroke:#1f3a93,stroke-width:4px,color:#7b241c", + "classDef fixedPath fill:#e9f7ef,stroke:#1f3a93,stroke-width:4px,color:#145a32", +]; + +export const GROUP_PALETTE = [ + "#d6eaf8", + "#fdebd0", + "#e8daef", + "#fcf3cf", + "#d1f2eb", + "#ede0d4", + "#f6e0ef", +]; +export const PLAIN_FILL = "#f4f6f7"; + +const rgb = (hex) => { + const h = hex.length === 4 ? [...hex.slice(1)].map((c) => c + c).join("") : hex.slice(1); + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); +}; +// WCAG relative luminance: 0 for black, 1 for white. +function luminance(hex) { + const [r, g, b] = rgb(hex).map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} +export function groupColorProblem(color) { + if (typeof color !== "string" || !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) + return 'is not "#rgb" or "#rrggbb"'; + if (luminance(color) < 0.5) return "is too dark for the status borders to show on it"; + return null; +} + +const BORDERS = { + Wrong: ["stroke:#c0392b,stroke-width:4px", "#7b241c"], + Fixed: ["stroke:#1e8449,stroke-width:4px", "#145a32"], + Path: ["stroke:#1f3a93,stroke-width:4px", "#1b2631"], +}; +export function groupClassDef(base, fill, border) { + const darker = `#${rgb(fill) + .map((c) => + Math.round(c / 2) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; + const [stroke, color] = BORDERS[border] ?? [ + `stroke:${fill === PLAIN_FILL ? "#566573" : darker}`, + "#1b2631", + ]; + return `classDef ${base}${border} fill:${fill},${stroke},color:${color}`; +} diff --git a/plugins/pstack/skills/statechart/scripts/model/OutputFolder.cfg b/plugins/pstack/skills/statechart/scripts/model/OutputFolder.cfg new file mode 100644 index 0000000..68fd715 --- /dev/null +++ b/plugins/pstack/skills/statechart/scripts/model/OutputFolder.cfg @@ -0,0 +1,6 @@ +SPECIFICATION Spec +INVARIANT ChartsOneSpec +INVARIANT KeepsYourFiles +INVARIANT NeverLosesChart +INVARIANT ExitTwoLeavesFolder +INVARIANT OverlapNeverRefuses diff --git a/plugins/pstack/skills/statechart/scripts/model/OutputFolder.map.mjs b/plugins/pstack/skills/statechart/scripts/model/OutputFolder.map.mjs new file mode 100644 index 0000000..9618338 --- /dev/null +++ b/plugins/pstack/skills/statechart/scripts/model/OutputFolder.map.mjs @@ -0,0 +1,106 @@ +const QUIET = ["idle", "exited", "stopped", "orphanTlc"]; +const going = (v) => v.pc.filter((p) => !QUIET.includes(p)).length; +const changed = (before, after) => + after.pc.findIndex( + (p, i) => p !== before.pc[i] || after.aside[i].exists !== before.aside[i].exists, + ) + 1; + +export const readerActions = ["Start", "Edit", "Note", "Stop", "KillNode"]; + +function context(v) { + if (v.pc.every((p) => p === "idle")) return "Before any run"; + if (going(v) === 2) return "Two runs going"; + return going(v) === 1 ? "One run going" : "Done"; +} + +const SPECS = { 1: "the spec", 2: "the edited spec", "1,2": "both specs" }; + +function contents(v) { + const kept = v.aside.filter((a) => a.exists); + const aside = kept.some((a) => a.notes) + ? ", your notes moved aside" + : kept.some((a) => a.by) + ? ", the old chart moved aside" + : ""; + const parts = [ + v.out.by && `chart of ${SPECS[v.out.read.join()]}`, + v.out.notes && "your notes", + ].filter(Boolean); + if (!v.out.exists) return `no folder${aside}`; + return `${parts.join(" and ") || "empty folder"}${aside}`; +} + +export function state(v) { + return `${context(v)}: ${contents(v)}`; +} + +export function group(v) { + if (v.pc.every((p) => p === "idle")) return "Not started"; + return going(v) ? "Running" : "Finished"; +} + +export function step(action, before, after) { + const r = changed(before, after); + switch (action) { + case "Start": { + const where = going(before) ? " in a second shell" : ""; + const how = after.inputOk[r - 1] ? "" : " with a typo"; + return `you start run ${r}${where}${how}`; + } + case "Refuse": + return before.inputOk[r - 1] + ? `run ${r} says the folder has a file that it did not write` + : `run ${r} says the input is wrong`; + case "Tlc": + return `run ${r}'s TLC reads the spec`; + case "Draw": + return `run ${r} draws the chart in its own folder`; + case "Stale": + return `run ${r} says the spec changed while it read it`; + case "Foreign": + return `run ${r} says the folder has a file that it did not write`; + case "Swap": + case "Checked": + return ""; + case "MoveIn": + return `run ${r} moves its folder into place`; + case "MoveAside": + return `run ${r} moves the old folder aside`; + case "Restore": + return `run ${r} puts the folder with your notes back and stops`; + case "Supersede": + return `run ${r} finds another run's folder in place and deletes the old folder`; + case "KeepAside": + return `run ${r} keeps the folder with your notes aside and says where`; + case "RenameFails": + if (after.aside[r - 1].exists) + return `run ${r} cannot rename a folder and says where the old folder is`; + return after.out.exists && !before.out.exists + ? `run ${r} cannot rename a folder, puts the old folder back, and stops` + : `run ${r} cannot rename a folder and stops`; + case "Clean": + return `run ${r} deletes the old folder and finishes`; + case "Edit": + return "you edit the spec"; + case "Note": + return "you save notes in the folder"; + case "Stop": + return `you stop run ${r} with Ctrl-C`; + case "KillNode": + return `you kill run ${r}'s Node, and its TLC keeps running`; + case "OrphanEnd": + return `run ${r}'s orphaned TLC finishes`; + default: + return action; + } +} + +export function cause(v, facts, invariant) { + return { + ChartsOneSpec: "the run read the spec before and after an edit, and still wrote its chart", + KeepsYourFiles: "a run deleted a folder that had your notes in it", + NeverLosesChart: "a run deleted the last chart before another chart was in place", + ExitTwoLeavesFolder: "a run wrote the folder and then said it could not finish", + OverlapNeverRefuses: "a run stopped only because another run overlapped it", + }[invariant]; +} diff --git a/plugins/pstack/skills/statechart/scripts/model/OutputFolder.tla b/plugins/pstack/skills/statechart/scripts/model/OutputFolder.tla new file mode 100644 index 0000000..7765bab --- /dev/null +++ b/plugins/pstack/skills/statechart/scripts/model/OutputFolder.tla @@ -0,0 +1,176 @@ +---- MODULE OutputFolder ---- +EXTENDS Naturals + +Runs == {1, 2} +NoRun == 0 +NoFolder == [exists |-> FALSE, by |-> NoRun, read |-> {}, notes |-> FALSE] + +VARIABLES pc, inputOk, snap, read, out, aside, spec, noted, wrote, broke, exitCode + +vars == <> + +Init == + /\ pc = [r \in Runs |-> "idle"] + /\ inputOk = [r \in Runs |-> TRUE] + /\ snap = [r \in Runs |-> 0] + /\ read = [r \in Runs |-> {}] + /\ out = NoFolder + /\ aside = [r \in Runs |-> NoFolder] + /\ spec = 1 + /\ noted = FALSE + /\ wrote = [r \in Runs |-> FALSE] + /\ broke = [r \in Runs |-> FALSE] + /\ exitCode = [r \in Runs |-> "none"] + +Set(f, r, v) == [f EXCEPT ![r] = v] +Going(r) == pc[r] \notin {"idle", "exited", "stopped", "orphanTlc"} +Go(r, to) == pc' = Set(pc, r, to) +Exit(r, code) == pc' = Set(pc, r, "exited") /\ exitCode' = Set(exitCode, r, code) + +Start(r) == + /\ pc[r] = "idle" + /\ r = 2 => pc[1] # "idle" + /\ \E ok \in BOOLEAN : inputOk' = Set(inputOk, r, ok) + /\ snap' = Set(snap, r, spec) + /\ Go(r, "check") + /\ UNCHANGED <> + +Refuse(r) == + /\ pc[r] = "check" + /\ ~inputOk[r] \/ out.notes + /\ Exit(r, "2") + /\ UNCHANGED <> + +Tlc(r) == + /\ \/ pc[r] = "check" /\ inputOk[r] /\ ~out.notes + \/ pc[r] = "tlc" + /\ read' = Set(read, r, read[r] \cup {spec}) + /\ Go(r, IF pc[r] = "check" THEN "tlc" ELSE "draw") + /\ UNCHANGED <> + +Draw(r) == + /\ pc[r] = "draw" + /\ Go(r, "publish") + /\ UNCHANGED <> + +Stale(r) == + /\ pc[r] = "publish" /\ spec # snap[r] + /\ Exit(r, "2") + /\ UNCHANGED <> + +Foreign(r) == + /\ pc[r] = "publish" /\ spec = snap[r] /\ out.notes + /\ Exit(r, "2") + /\ UNCHANGED <> + +Swap(r) == + /\ pc[r] = "publish" /\ spec = snap[r] /\ ~out.notes + /\ Go(r, "swap") + /\ UNCHANGED <> + +MoveIn(r) == + /\ pc[r] = "swap" /\ ~out.exists + /\ out' = [exists |-> TRUE, by |-> r, read |-> read[r], notes |-> FALSE] + /\ wrote' = Set(wrote, r, TRUE) + /\ Go(r, "clean") + /\ UNCHANGED <> + +MoveAside(r) == + /\ pc[r] = "swap" /\ out.exists /\ ~aside[r].exists + /\ aside' = Set(aside, r, out) + /\ out' = NoFolder + /\ Go(r, "aside") + /\ UNCHANGED <> + +Checked(r) == + /\ pc[r] = "aside" /\ ~aside[r].notes + /\ Go(r, "swap") + /\ UNCHANGED <> + +Supersede(r) == + /\ pc[r] = "swap" /\ out.exists /\ aside[r].exists /\ ~out.notes + /\ aside' = Set(aside, r, NoFolder) + /\ UNCHANGED <> + +Restore(r) == + /\ pc[r] = "aside" /\ aside[r].notes /\ ~out.exists + /\ out' = aside[r] + /\ aside' = Set(aside, r, NoFolder) + /\ Exit(r, "2") + /\ UNCHANGED <> + +KeepAside(r) == + /\ \/ pc[r] = "aside" /\ aside[r].notes /\ out.exists + \/ pc[r] = "swap" /\ aside[r].exists /\ out.notes + /\ Exit(r, "2") + /\ UNCHANGED <> + +RenameFails(r) == + /\ pc[r] \in {"swap", "aside"} + /\ \/ /\ aside[r].exists /\ ~out.exists + /\ out' = aside[r] + /\ aside' = Set(aside, r, NoFolder) + \/ UNCHANGED <> + /\ broke' = Set(broke, r, TRUE) + /\ Exit(r, "2") + /\ UNCHANGED <> + +Clean(r) == + /\ pc[r] = "clean" + /\ aside' = Set(aside, r, NoFolder) + /\ Exit(r, "0") + /\ UNCHANGED <> + +Stop(r) == + /\ Going(r) + /\ Go(r, "stopped") + /\ UNCHANGED <> + +KillNode(r) == + /\ pc[r] = "tlc" + /\ Go(r, "orphanTlc") + /\ UNCHANGED <> + +OrphanEnd(r) == + /\ pc[r] = "orphanTlc" + /\ Go(r, "stopped") + /\ UNCHANGED <> + +Edit == + /\ spec = 1 + /\ spec' = 2 + /\ UNCHANGED <> + +Note == + /\ ~noted + /\ noted' = TRUE + /\ out' = [out EXCEPT !.exists = TRUE, !.notes = TRUE] + /\ UNCHANGED <> + +Next == + \/ Edit \/ Note + \/ \E r \in Runs : + Start(r) \/ Refuse(r) \/ Tlc(r) \/ Draw(r) \/ Stale(r) \/ Foreign(r) \/ Swap(r) + \/ MoveIn(r) \/ MoveAside(r) \/ Checked(r) \/ Supersede(r) \/ Restore(r) + \/ KeepAside(r) \/ RenameFails(r) \/ Clean(r) \/ Stop(r) \/ KillNode(r) \/ OrphanEnd(r) + +Spec == Init /\ [][Next]_vars + +\* The folder never holds a chart drawn from two versions of the spec. +ChartsOneSpec == out.read \in {{}, {1}, {2}} + +\* A file that you put in the folder is never deleted. +KeepsYourFiles == noted => out.notes \/ \E r \in Runs : aside[r].notes + +\* Once a run writes a chart, the folder or a folder that a run moved aside always holds one. +NeverLosesChart == + (\E r \in Runs : wrote[r]) => out.by # NoRun \/ \E r \in Runs : aside[r].by # NoRun + +\* A run that exits 2 did not write the folder. +ExitTwoLeavesFolder == \A r \in Runs : exitCode[r] = "2" => ~wrote[r] + +\* A run exits 2 only for a wrong input, an edited spec, a file that it did not write, or a failed +\* rename, never because another run overlaps it. +OverlapNeverRefuses == + \A r \in Runs : exitCode[r] = "2" => ~inputOk[r] \/ spec # snap[r] \/ noted \/ broke[r] +==== diff --git a/plugins/pstack/skills/statechart/scripts/statechart.mjs b/plugins/pstack/skills/statechart/scripts/statechart.mjs new file mode 100644 index 0000000..6e2ebfc --- /dev/null +++ b/plugins/pstack/skills/statechart/scripts/statechart.mjs @@ -0,0 +1,749 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { checkTlcOutput, findTlc, parseDot } from "./lib.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +const USAGE = { + "from-spec": + "node statechart.mjs from-spec SPEC.tla [--fixed FIXED.tla | --against AGREED.tla] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]", + "from-chart": + "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]", +}; +const usage = (lines) => `Usage: ${lines.join("\n ")}`; + +const FROM_SPEC = `from-spec SPEC.tla + Checks each INVARIANT in SPEC.cfg in a separate TLC run. TLC stops at the first rule that a state + breaks, and the separate runs find the rest. Each run also has the rule as a CONSTRAINT, so TLC + stops each path at its first state that breaks the rule, and a rule that stays broken reports + only the step that broke it. SPEC.cfg sits next to the spec and has SPECIFICATION and one + INVARIANT line per rule. One more run dumps the state graph, and then the command draws the + charts. TLC names each step after the innermost named action that takes it, so give each kind of + step its own named action. The command warns when steps carry the name of the whole next-state + action. + + --fixed FIXED.tla The model of the open fix. It is a copy of the spec with its own file and + module name, such as SpecFixed.tla with MODULE SpecFixed. The command checks + it the same way, with FIXED.cfg, or with SPEC.cfg when there is no FIXED.cfg. + The charts then mark each bad state fixed or still wrong. + --against AGREED.tla + The spec that SPEC.tla must follow, when SPEC.tla models the code. It needs + its own file and module name. The command checks it with AGREED.cfg, or with + SPEC.cfg when there is no AGREED.cfg. SPEC.tla may add variables, record + fields, and actions that AGREED.tla does not have. The comparison leaves them + out. Do not use it with --fixed. + --label TEXT The name of the --fixed or --against model in the charts and the stories. + Default: "the fix", or "the spec". + --map FILE The labels for each model state and step. Default: SPEC.map.mjs next to the + spec. When the file does not exist, the command writes one to fill in and + exits 2. Its comments say what each function gets.`; + +const FROM_CHART = `from-chart CHART.mmd + Writes NAME.tla, NAME.cfg, and NAME.map.mjs next to the chart. When NAME.tla already exists, it + writes none of them. Then it checks the spec as from-spec does, and compares the chart that the + spec draws with CHART.mmd. + + --module NAME The TLA+ module name. Default: the chart's file name in CamelCase, such as + NotesApp for notes-app.mmd. + + The chart is a Mermaid stateDiagram-v2. Each transition label is one action. For parallel + regions, split one top-level composite with --, and put each part in a named composite, such as + state Connection { ... }. Each region is one variable. A step moves every region whose current + state has a transition with the step's label, and the other regions stay where they are. Each + rule is one comment line: + %% never NAME: A & B -- the rule in one sentence + Each name after the colon is a state caption or a composite, and each comes from a different + region. A composite stands for every state in it. The command refuses final, choice, fork, and + history states, and regions below the top level.`; + +const COMMON = `Both commands + --expect-broken RULE + A rule that is known to break. Repeat it, or list rules with commas, as in + A,B. The command then exits 0 when exactly these rules break, and the --fixed + or --against model breaks no other rule. + --title TEXT The chart title. Each bug chart adds ", bug N". + --out DIR The output folder. Default: NAME-statechart next to the input. A run writes its + files in a temporary folder, and then replaces the whole output folder with + it. When runs overlap, the output folder has the files of the run that + finished last. The command does not use an output folder that has files that + it did not write. + --png Also render each chart to PNG with npx @mermaid-js/mermaid-cli. + -h, --help Print this help, and exit 0. + +Output + The command prints whether each rule holds, the chart's counts, and one story per bug. + from-chart also prints the comparison. The command writes these files in the output folder: + chart.mmd The overview. + bug-N.mmd Only the steps of bug N, numbered. Bugs are numbered by fewest reader steps, + then fewest model steps, then rule name, then cause. Bugs that only the + --fixed or --against model has come last. + bug-N-developer.mmd, bug-N-layered.mmd + The path of bug N through the developer's states, when the mapping exports + detail(). + stories.md One entry per bug: the rule, why it breaks, the shortest path in reader + steps, and what the --fixed or --against model does instead. + summary.json The counts behind the chart. + tlc/ The config and the TLC output of each run. When the overview has regions, + from-spec also writes tlc/regions-all.mmd, the regions with every arrow, and + tlc/regions-proof.mmd, every state and step of TLC's graph in one region. + compare-charts.mjs tlc/regions-all.mmd tlc/regions-proof.mmd repeats the + proof. + *.png Each chart as a PNG, with --png. A run without --png deletes them. + +Reading a chart + from-spec draws the overview as two parallel regions when every state reads "context: progress": + the contexts on one side, the progress on the other, both at the same time. It does so only when + that has fewer states, and when the regions combine back into exactly every state and step of + TLC's graph. Otherwise it puts the states of one context in a box named context, and prints why. + Regions draw only the arrows of the shortest way to each state, of each bug path, and of the + nearest step with each label. The script checks that these arrows make no step that TLC's graph + does not have. + An arrow from a box's border leaves from every state in the box. + In regions, a step leaves every region that has it. "[when A]" or "[unless A]" says which states + of the other region the step needs. A loop says the region may also stay. + Other words in [ ] come from the mapping's guard(), and "after N s" from its timer(). TLC does + not check them, and has no clock, so a reviewer checks them against the code. + Gray states work. Red states break a rule. With --fixed, red states stay wrong with the fix, and + green states are fixed by it or exist only with it. With --against, red states break a rule in + either model, and green states exist only in the spec. + When the mapping exports group(), the fill is the state's group, which the legend names, and the + status is a thick red or green border on top. tlc-to-chart.mjs --help tells more. + A state is bad when it breaks a rule, or when every path from the start to it passes a state + that breaks one. TLC stops each path at the first state that breaks the rule, so a rule that + stays broken colors only the states where it first breaks and the states after them that no + path without a bug reaches. + A thick blue border and numbered labels mark the bug. Mermaid state diagrams cannot color an + edge. + A label "with the fix, STEP" or "in the spec, STEP" is a step that only the other model takes. On + a bug's path it leaves the state before the first step that the other model shows the reader + differently, and ends where that model goes instead. "with the fix, STEP cannot happen" or "in + the spec, STEP cannot happen" loops on the state where that model stops the step. Outside + regions, two states have at most one arrow between them. Its lines are the numbered steps, then + SPEC.tla's steps, then the other model's steps, with a blank line between each group. + An edge lists every step that takes it, most common first. Words that the steps share at the + start are written once, as in "you tap Pay or Back". + +Regenerate + With --expect-broken, one script rebuilds every chart and stops on any change: + set -e + node statechart.mjs from-spec Code.tla --against Agreed.tla --expect-broken NoLostUpload --png + +TLC + TLA2TOOLS is the path to tla2tools.jar, and JAVA is the java binary (default: java, Java 11 or + later). Download tla2tools.jar from https://github.com/tlaplus/tlaplus/releases. When TLA2TOOLS + is not set, the command says where to get TLC and exits 2. + +Exit codes + 0 Every rule holds, or exactly the --expect-broken rules break. from-chart also draws CHART.mmd + back unchanged. + 1 Another rule is broken, an expected rule holds, or the chart that the spec draws is not + CHART.mmd. The output says which. + 2 The command cannot finish: a bad argument, a missing file, a chart or TLC output that it + cannot read, no TLC, a helper script that cannot start, an output folder with files that the + command did not write, an input file that changed during the run, a new mapping to fill in, + or a chart that --png cannot render. When a chart does not render, the command keeps the + files it wrote and exits 2, whatever the rules give. Otherwise, exit 2 does not change the + output folder, unless the command moved it aside and cannot put it back. Then the message + names the folder's new path. When TLC stops on an error, the message names TLC's output, in a + temporary folder that the command keeps.`; + +const HELP = { + "": `${usage([...Object.values(USAGE), "node statechart.mjs [from-spec|from-chart] --help"])} + +Draws a Mermaid statechart of what an app's user sees, from TLC's state graph of a TLA+ spec. The +help calls that user the reader. + +${FROM_SPEC} + +${FROM_CHART} + +${COMMON}`, + "from-spec": `${usage([USAGE["from-spec"]])}\n\n${FROM_SPEC}\n\n${COMMON}`, + "from-chart": `${usage([USAGE["from-chart"]])}\n\n${FROM_CHART}\n\n${COMMON}`, +}; + +const more = (cmd) => + `Run node statechart.mjs ${cmd ? `${cmd} ` : ""}--help for the flags, the output, and the exit codes.`; + +class CannotFinish extends Error {} +class TlcStopped extends CannotFinish {} + +const COMMANDS = { + "from-spec": { + flags: ["--map", "--fixed", "--against", "--label", "--expect-broken", "--title", "--out"], + run: fromSpec, + }, + "from-chart": { flags: ["--module", "--expect-broken", "--title", "--out"], run: fromChart }, +}; + +function parseArgs([command, ...rest]) { + if (command === "-h" || command === "--help") return { help: HELP[""] }; + if (!Object.hasOwn(COMMANDS, command)) + throw new CannotFinish(`${usage(Object.values(USAGE))}\n${more()}`); + const flags = { "expect-broken": new Set() }; + let input; + for (let i = 0; i < rest.length; i++) { + const a = rest[i]; + if (a === "-h" || a === "--help") return { help: HELP[command] }; + if (a === "--png") flags.png = true; + else if (COMMANDS[command].flags.includes(a)) { + if (i + 1 === rest.length) throw new CannotFinish(`${a} needs a value. ${more(command)}`); + const name = a.slice(2); + if (flags[name] instanceof Set) for (const v of rest[++i].split(",")) flags[name].add(v); + else flags[name] = rest[++i]; + } else if (!a.startsWith("-") && input === undefined) input = a; + else throw new CannotFinish(`unknown argument ${a}. ${more(command)}`); + } + if (input === undefined) throw new CannotFinish(`${usage([USAGE[command]])}\n${more(command)}`); + return { command, input, flags }; +} + +const rel = (file) => path.relative(process.cwd(), file) || "."; +const cfgOf = (spec) => spec.replace(/\.tla$/, ".cfg"); + +function findTools() { + try { + return findTlc(); + } catch (e) { + throw new CannotFinish(e.message); + } +} + +function script(name, scriptArgs, about, { okExits = [0] } = {}) { + const r = spawnSync(process.execPath, [path.join(here, name), ...scriptArgs], { + encoding: "utf8", + }); + if (r.error) throw new CannotFinish(`cannot run ${name}: ${r.error.message}`); + if (okExits.includes(r.status)) return r; + const thrown = r.stderr.match(/^Error: (.*)$/m)?.[1]; + throw new CannotFinish(thrown ? `${rel(about)}: ${thrown}` : r.stderr.trim()); +} + +const KEYWORD = + /^(SPECIFICATION|INIT|NEXT|CONSTANTS?|INVARIANTS?|PROPERTY|PROPERTIES|SYMMETRY|VIEW|CONSTRAINTS?|ACTION_CONSTRAINTS?|CHECK_DEADLOCK|POSTCONDITION|ALIAS)$/; + +function splitRules(cfg) { + const words = cfg + .replace(/\(\*[^]*?\*\)/g, " ") + .replace(/\\\*.*/g, " ") + .split(/\s+/) + .filter(Boolean); + const kept = []; + const rules = []; + let section = ""; + for (const w of words) { + if (KEYWORD.test(w)) section = w; + if (!/^INVARIANTS?$/.test(section)) kept.push(KEYWORD.test(w) && kept.length ? `\n${w}` : w); + else if (w !== section && !rules.includes(w)) rules.push(w); + } + return { cfgWithoutRules: `${kept.join(" ")}\n`, rules }; +} + +function tlc(tools, spec, cfg, base, dump) { + fs.writeFileSync(`${base}.cfg`, cfg); + const metadir = fs.mkdtempSync(path.join(os.tmpdir(), "tlc-")); + // TLC writes the standard modules, such as Naturals.tla, to java.io.tmpdir under fixed names, + // so runs that share that folder can read a module while another run rewrites it. + const javaTmp = path.join(metadir, "tmp"); + fs.mkdirSync(javaTmp); + // -deadlock: a UI at rest has nothing left to do, which TLC would report as a deadlock. + const r = spawnSync( + tools.java, + [ + `-Djava.io.tmpdir=${javaTmp}`, + "-cp", + tools.jar, + "tlc2.TLC", + "-metadir", + metadir, + "-deadlock", + "-continue", + ...(dump ? ["-dump", "dot,actionlabels", path.resolve(dump)] : []), + "-config", + path.resolve(`${base}.cfg`), + path.basename(spec), + ], + { cwd: path.dirname(path.resolve(spec)), encoding: "utf8", maxBuffer: 1 << 30 }, + ); + fs.rmSync(metadir, { recursive: true, force: true }); + if (r.error) throw new CannotFinish(`cannot run TLC: ${r.error.message}`); + fs.writeFileSync(`${base}.out`, r.stdout); + try { + checkTlcOutput(r.stdout, `${base}.out`); + } catch (e) { + throw new TlcStopped(e.message); + } + return r.stdout; +} + +function warnUnnamedSteps(spec, cfg, dot) { + const text = fs + .readFileSync(spec, "utf8") + .replace(/\(\*[^]*?\*\)/g, " ") + .replace(/\\\*.*/g, " "); + const specName = /^SPECIFICATION\s+(\w+)/m.exec(cfg)?.[1]; + const specBody = specName && new RegExp(`^${specName}\\s*==(.*(\\n[ \\t]+.*)*)`, "m").exec(text); + const next = + /^NEXT\s+(\w+)/m.exec(cfg)?.[1] ?? /\[\]\s*\[\s*(\w+)\s*\]_/.exec(specBody?.[1] ?? text)?.[1]; + const graph = parseDot(fs.readFileSync(dot, "utf8")); + const unnamed = [...graph.edges, ...graph.loops].filter((e) => e.action === next).length; + if (unnamed > 0) + console.error( + `${rel(spec)}: ${unnamed} ${unnamed === 1 ? "step has" : "steps have"} the name ${next}, the whole next-state action, because TLC names a step after the innermost named action that takes it. If the reader must tell these steps apart, give each part of ${next} its own name, as in Pay == ... and ${next} == Pay \\/ Charge.`, + ); +} + +function readModel(spec, cfgFile) { + if (!fs.existsSync(spec)) throw new CannotFinish(`${rel(spec)} does not exist`); + if (!fs.existsSync(cfgFile)) + throw new CannotFinish( + `${rel(cfgFile)} does not exist. It needs SPECIFICATION and one INVARIANT line per rule.`, + ); + return { spec, cfgFile, ...splitRules(fs.readFileSync(cfgFile, "utf8")) }; +} + +function readInput(spec, expected) { + const model = readModel(spec, cfgOf(spec)); + const unknown = [...expected].filter((r) => !model.rules.includes(r)); + if (unknown.length) + throw new CannotFinish( + `--expect-broken ${unknown.join(", ")}: not a rule in ${rel(model.cfgFile)}, whose rules are ${model.rules.join(", ") || "none"}.`, + ); + return model; +} + +function check(tools, { spec, cfgFile, cfgWithoutRules, rules }, work, expected) { + const name = path.basename(spec, ".tla"); + const tlcDir = path.join(work, "tlc"); + const dot = path.join(tlcDir, `${name}.dot`); + tlc(tools, spec, cfgWithoutRules, path.join(tlcDir, name), dot); + warnUnnamedSteps(spec, cfgWithoutRules, dot); + console.log(`TLC on ${path.basename(spec)}:`); + if (rules.length === 0) + console.log(` ${path.basename(cfgFile)} has no INVARIANT, so no rule is checked.`); + // With -continue TLC reports every bad state. The CONSTRAINT stops each path at its first bad + // state, so a rule that stays broken reports only the step that broke it, not every later state. + const traces = rules.map((rule) => { + const file = path.join(tlcDir, `${name}-${rule}`); + const cfg = `${cfgWithoutRules}INVARIANT ${rule}\nCONSTRAINT ${rule}\n`; + const broken = /^Error: Invariant /m.test(tlc(tools, spec, cfg, file)); + console.log( + ` ${rule} ${broken ? (expected.has(rule) ? "is broken (expected)" : "is broken") : "holds"}.`, + ); + return { file: `${file}.out`, rule, broken }; + }); + return { spec, dot, traces, broken: traces.some((t) => t.broken) }; +} + +function rulesAsExpected(now, other, expected) { + const brokenIn = (m) => (m?.traces ?? []).filter((t) => t.broken).map((t) => t.rule); + if (expected.size === 0) return !now.broken && !other?.broken; + const unexpected = [...new Set([...brokenIn(now), ...brokenIn(other)])].filter( + (r) => !expected.has(r), + ); + const holds = [...expected].filter((r) => !brokenIn(now).includes(r)); + if (unexpected.length) console.log(`\nBroken but not expected: ${unexpected.join(", ")}.`); + if (holds.length) console.log(`\nExpected to break but holds: ${holds.join(", ")}.`); + if (!unexpected.length && !holds.length) + console.log(`\nBroken as expected: ${[...expected].join(", ")}.`); + return !unexpected.length && !holds.length; +} + +const SCAFFOLD = (name, graph) => { + const vars = Object.keys([...graph.nodes.values()][0]?.vars ?? {}); + const actions = [...new Set([...graph.edges, ...graph.loops].map((e) => e.action))].sort(); + return `// Chart labels for ${name}, for statechart.mjs. Write labels in plain words, not variable or action names. +// v, before, and after map each variable to its value. Strings, numbers, and booleans keep their kind. +// Sequences, tuples, and sets become arrays, indexed from 0. Records and functions become objects. +// Variables: ${vars.join(", ")}. + +// The actions the reader takes. facts.settled is true when only these are enabled, and the overview +// keeps the shortest path to each settled state, plus every bug path. +export const readerActions = []; + +// The label for one model state. facts.enabled is the set of actions the state enables, +// including steps that change no variable. Write it as "context: progress", such as +// "Offline: upload queued": the chart puts the states of one context in one box. +// Do not claim a result that a pending step can still change, such as "upload failed" while a +// retry is still queued. +export function state(v, facts) { + return JSON.stringify(v); +} + +// The names of the two parallel regions, when the overview draws them: contexts, then progress. +// export const regionNames = ["Network", "What happened to the file"]; + +// The group of one model state, in words the reader sees, such as "Upload done". The chart fills +// the states of one group with one color, and the legend names the groups. TLC still decides +// which states break a rule, and draws them with a thick red border on top of the group color. +// groupColors gives a group a light color. Groups without one take a palette color. +// export function group(v, facts) {} +// export const groupColors = { "Upload done": "#d1f2eb" }; + +// The label for one step. A step that leaves the reader in the same state is not drawn, +// unless the reader takes it on a bug path. "" draws no label, and a numbered bug step shows the action. +const STEPS = { +${actions.map((a) => `\t${a}: ${JSON.stringify(a)},`).join("\n")} +}; +export function step(action, before, after) { + return STEPS[action] ?? action; +} + +// When the step can happen, in plain words, such as "offline or disk full". The chart shows +// "step [words]". "" adds nothing. +// export function guard(action, before, after) {} + +// For a timer step, how many seconds the code waits, from the constant in the code. The chart +// shows "after N s, step". Return nothing for other steps. +// export function timer(action, before, after) {} + +// Why a bad state is bad, in words a product manager reads, such as "the laptop went offline, and +// the app drops an upload it cannot send". No variable, function, or event names. +// Counterexamples that break one rule with one cause are one bug, so two bugs that end on the +// same screen stay apart only if their causes differ. Return null to group by the end state. +// export function cause(v, facts, invariant) {} + +// The developer's words for one model state. With it, each bug is also drawn through these states. +// export function detail(v, facts) {} + +// The legend's words for each status, in place of the defaults. Group entries keep their names. +// export const legend = { works: "Works on main", wrong: "Wrong on main", fixed: "Fixed in the open PR" }; +`; +}; + +async function draw({ now, other, map, work, out, title, comparing }) { + const shown = published(work, out); + const mapping = await import(pathToFileURL(path.resolve(map)).href); + const from = ["--dot", now.dot, "--map", map, "--spec", now.spec]; + from.push(...now.traces.flatMap((t) => ["--traces", t.file])); + if (other) { + from.push(`--${other.role}-dot`, other.dot, "--spec", other.spec); + from.push(...other.traces.flatMap((t) => [`--${other.role}-traces`, t.file])); + if (other.label !== undefined) from.push("--label", other.label); + } + if (comparing) from.push("--full-labels", "--flat"); + else if (mapping.readerActions?.length) from.push("--cover"); + const titled = (suffix) => (title ? ["--title", `${title}${suffix}`] : []); + + const files = { + overview: path.join(work, "chart.mmd"), + stories: path.join(work, "stories.md"), + summary: path.join(work, "summary.json"), + }; + const proof = path.join(work, "tlc", "regions-proof.mmd"); + const everyArrow = path.join(work, "tlc", "regions-all.mmd"); + script( + "tlc-to-chart.mjs", + [ + ...from, + ...(comparing ? [] : ["--regions", "--proof", proof, "--regions-all", everyArrow]), + ...titled(""), + "--highlight", + "0", + "--out", + files.overview, + "--story", + files.stories, + "--summary", + files.summary, + ], + map, + ); + const summary = JSON.parse(fs.readFileSync(files.summary, "utf8")); + const charts = [files.overview]; + for (const c of summary.classes) { + const views = typeof mapping.detail === "function" ? ["", "-developer", "-layered"] : [""]; + for (const view of views) { + const file = path.join(work, `bug-${c.n}${view}.mmd`); + const shape = [...(view ? ["--view", view.slice(1)] : []), "--path-only"]; + script( + "tlc-to-chart.mjs", + [...from, ...shape, ...titled(`, bug ${c.n}`), "--highlight", String(c.n), "--out", file], + map, + ); + charts.push(file); + } + } + + const { works, wrong, fixed: fixedCount } = summary.chartStatus; + const L = summary.second?.label; + console.log( + `\nChart: ${summary.chartStates} states and ${summary.chartTransitions} transitions from ${summary.modelStates} model states.`, + ); + const r = summary.regions; + if (r?.drawn) + console.log( + `It has 2 parallel regions, of ${r.states[0]} and ${r.states[1]} states. Proof: their ${r.all} arrows together make exactly the ${r.flat.states} states and ${r.flat.transitions} labeled steps of ${shown(proof)}, and the ${r.transitions} drawn arrows make only steps among them. ${shown(everyArrow)} has all ${r.all} arrows.`, + ); + else if (r) console.log(`It has no parallel regions, because ${r.why}.`); + const breaks = `${wrong} ${wrong === 1 ? "breaks" : "break"} a rule`; + console.log( + other?.role === "fixed" + ? `${works} work today, ${wrong} still wrong with ${L}, ${fixedCount} fixed or new with ${L}.\n` + : other?.role === "against" + ? `${works} work, ${breaks}, ${fixedCount} only in ${L}.\n` + : `${works} work, ${breaks}.\n`, + ); + if (summary.codeLike.length) + console.log( + `These labels or causes read like code. Write them in the reader's words in ${rel(map)}:\n${summary.codeLike.map((t) => ` ${t}`).join("\n")}\n`, + ); + console.log( + summary.classes.length + ? fs.readFileSync(files.stories, "utf8").trimEnd() + : "No rule is broken.", + ); + return { files, charts }; +} + +function render(charts, shown) { + const pngs = []; + for (const chart of charts) { + const png = chart.replace(/\.mmd$/, ".png"); + const r = spawnSync( + "npx", + ["-y", "@mermaid-js/mermaid-cli", "-i", chart, "-o", png, "-s", "2"], + { + encoding: "utf8", + }, + ); + if (r.status === 0) pngs.push(png); + else + console.error( + `could not render ${shown(chart)}: ${(r.stderr || r.error?.message || "").trim()}`, + ); + } + return pngs; +} + +function finish({ work, out, inputs }, { files, charts }, ok, png) { + const pngs = png ? render(charts, published(work, out)) : []; + publish(work, out, inputs); + const written = [...charts, ...pngs, files.stories, files.summary] + .map((f) => path.basename(f)) + .join(", "); + console.log(`\nWrote ${written} in ${rel(out)}.`); + if (png && pngs.length < charts.length) return 2; + return ok ? 0 : 1; +} + +const published = (work, out) => (file) => rel(path.join(out, path.relative(work, file))); + +const OWN = + /^((chart|bug-\d+(-developer|-layered)?)\.(mmd|png)|stories\.md|summary\.json|tlc|\.DS_Store)$/; + +function foreign(dir) { + try { + return fs + .readdirSync(dir) + .filter((f) => !OWN.test(f)) + .sort(); + } catch (e) { + if (e.code === "ENOENT") return []; + throw new CannotFinish(`cannot use ${rel(dir)} as the output folder: ${e.message}`); + } +} + +function refuseForeign(out, files = foreign(out)) { + const [first, ...others] = files; + if (first) + throw new CannotFinish( + `${rel(out)} has ${others.length ? `${others.length + 1} files that this command did not write, such as ${first}` : `a file that this command did not write: ${first}`}. A run puts its files in place of the whole output folder, so pass --out with a new folder.`, + ); +} + +function outDir(flagged, input, name) { + const out = flagged ?? path.join(path.dirname(input), `${name}-statechart`); + refuseForeign(out); + return out; +} + +const fingerprint = (file) => { + try { + const { ino, ctimeNs } = fs.statSync(file, { bigint: true }); + return `${ino} ${ctimeNs} ${fs.readFileSync(file, "base64")}`; + } catch { + return null; + } +}; + +function snapshot(files, inputs = new Map()) { + for (const f of files) if (f && !inputs.has(f)) inputs.set(f, fingerprint(f)); + return inputs; +} + +async function inWorkFolder(body) { + const work = fs.mkdtempSync(path.join(os.tmpdir(), "statechart-")); + fs.mkdirSync(path.join(work, "tlc")); + let keep = false; + try { + return await body(work); + } catch (e) { + keep = e instanceof TlcStopped; + throw e; + } finally { + if (!keep) fs.rmSync(work, { recursive: true, force: true }); + } +} + +const moved = (from, to, ...missed) => { + try { + fs.renameSync(from, to); + return true; + } catch (e) { + if (missed.includes(e.code)) return false; + throw e; + } +}; + +function publish(work, out, inputs) { + const parent = path.dirname(path.resolve(out)); + fs.mkdirSync(parent, { recursive: true }); + const next = fs.mkdtempSync(path.join(parent, `.${path.basename(out)}-`)); + const old = `${next}-old`; + let aside = false; + try { + fs.cpSync(work, next, { recursive: true }); + const changed = [...inputs].find(([f, was]) => fingerprint(f) !== was)?.[0]; + if (changed) + throw new CannotFinish( + `${rel(changed)} changed while this run read it, so the run left ${rel(out)} as it was. Run the command again.`, + ); + refuseForeign(out); + while (!moved(next, out, "ENOTEMPTY", "EEXIST")) { + if (aside) { + refuseForeign(out); + fs.rmSync(old, { recursive: true, force: true }); + aside = false; + } + aside = moved(out, old, "ENOENT"); + if (aside) refuseForeign(out, foreign(old)); + } + } catch (e) { + const back = !aside || putBack(old, out); + fs.rmSync(next, { recursive: true, force: true }); + const why = e instanceof CannotFinish ? e.message : `cannot replace ${rel(out)}: ${e.message}.`; + if (!back) + throw new CannotFinish( + `${why} This run moved the previous ${rel(out)} to ${rel(old)} and could not put it back.`, + ); + throw e instanceof CannotFinish + ? e + : new CannotFinish(`${why} The run left ${rel(out)} as it was.`); + } + if (aside) fs.rmSync(old, { recursive: true, force: true }); +} + +function putBack(old, out) { + try { + return moved(old, out, "ENOTEMPTY", "EEXIST"); + } catch { + return false; + } +} + +function secondModel(input, { fixed, against, label }) { + if (fixed && against) + throw new CannotFinish(`use --fixed or --against, not both. ${more("from-spec")}`); + if (!fixed && !against) { + if (label === undefined) return null; + throw new CannotFinish( + `--label names the --fixed or --against model; it needs one of them. ${more("from-spec")}`, + ); + } + const [role, spec] = fixed ? ["fixed", fixed] : ["against", against]; + const name = path.basename(input, ".tla"); + // TLC's output files are named after the spec, so a second model with the same name overwrites them. + if (path.basename(spec, ".tla") === name) + throw new CannotFinish( + `${rel(spec)} needs its own file and module name, such as ${name}${fixed ? "Fixed" : "Agreed"}.tla.`, + ); + const cfg = fs.existsSync(cfgOf(spec)) ? cfgOf(spec) : cfgOf(input); + return { role, label, model: readModel(spec, cfg) }; +} + +async function fromSpec(input, { "expect-broken": expected, ...flags }) { + const name = path.basename(input, ".tla"); + const map = flags.map ?? path.join(path.dirname(input), `${name}.map.mjs`); + const secondSpec = flags.fixed ?? flags.against; + const inputs = snapshot([input, cfgOf(input), map, secondSpec, secondSpec && cfgOf(secondSpec)]); + const second = secondModel(input, flags); + const tools = findTools(); + const model = readInput(input, expected); + const out = outDir(flags.out, input, name); + return inWorkFolder(async (work) => { + const now = check(tools, model, work, expected); + if (!fs.existsSync(map)) { + fs.writeFileSync(map, SCAFFOLD(name, parseDot(fs.readFileSync(now.dot, "utf8")))); + throw new CannotFinish( + `\nWrote ${rel(map)}. Fill in its labels, then run this command again.`, + ); + } + const other = second && { + role: second.role, + label: second.label, + ...check(tools, second.model, work, expected), + }; + const drawn = await draw({ now, other, map, work, out, title: flags.title }); + return finish({ work, out, inputs }, drawn, rulesAsExpected(now, other, expected), flags.png); + }); +} + +async function fromChart(input, { "expect-broken": expected, ...flags }) { + const name = + flags.module ?? + path + .basename(input, path.extname(input)) + .replace(/(^|[^A-Za-z0-9]+)([A-Za-z0-9])/g, (_, __, c) => c.toUpperCase()); + if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) + throw new CannotFinish(`cannot make a TLA+ module name from ${input}; add --module NAME`); + const spec = path.join(path.dirname(input), `${name}.tla`); + const map = spec.replace(/\.tla$/, ".map.mjs"); + const inputs = snapshot([input]); + if (fs.existsSync(spec)) { + console.log(`Kept ${rel(spec)}. Delete it to write the spec again from ${rel(input)}.`); + } else { + script("chart-to-tla.mjs", [input, "--module", name], input); + console.log(`Wrote ${name}.tla, ${name}.cfg, and ${name}.map.mjs from ${rel(input)}.`); + } + snapshot([spec, cfgOf(spec), map], inputs); + const tools = findTools(); + const model = readInput(spec, expected); + const out = outDir(flags.out, input, name); + return inWorkFolder(async (work) => { + const now = check(tools, model, work, expected); + const drawn = await draw({ now, map, work, out, title: flags.title, comparing: true }); + const compared = script("compare-charts.mjs", [input, drawn.files.overview], input, { + okExits: [0, 1], + }); + if (compared.status === 0) console.log(`\nThe spec draws ${rel(input)} back unchanged.`); + else + console.log( + `\nThe spec does not draw ${rel(input)}:\n${compared.stdout.trimEnd().replace(/^/gm, " ")}`, + ); + return finish( + { work, out, inputs }, + drawn, + rulesAsExpected(now, null, expected) && compared.status === 0, + flags.png, + ); + }); +} + +try { + const { help, command, input, flags } = parseArgs(process.argv.slice(2)); + if (help) { + console.log(help); + process.exit(0); + } + process.exit(await COMMANDS[command].run(input, flags)); +} catch (e) { + if (!(e instanceof CannotFinish)) throw e; + console.error(e.message); + process.exit(2); +} diff --git a/plugins/pstack/skills/statechart/scripts/test/statechart.test.mjs b/plugins/pstack/skills/statechart/scripts/test/statechart.test.mjs new file mode 100644 index 0000000..a90c7e4 --- /dev/null +++ b/plugins/pstack/skills/statechart/scripts/test/statechart.test.mjs @@ -0,0 +1,2790 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { test } from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { + checkTlcOutput, + findTlc, + parseDot, + parseMermaid, + parseTraces, + parseValue, + ruleSentences, + stateKey, +} from "../lib.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const scripts = dirname(here); +const fixture = (name) => readFileSync(join(here, name), "utf8"); +const edges = (r) => r.transitions.map((t) => `${t.from} -> ${t.to} : ${t.label}`).sort(); +const node = (script, args, cwd, env = {}) => + spawnSync(process.execPath, [join(scripts, script), ...args], { + cwd, + encoding: "utf8", + env: { ...process.env, ...env }, + timeout: 120_000, + }); + +test("a transition on a composite leaves from every state inside it", () => { + const { regions } = parseMermaid(fixture("upload.mmd")); + const main = regions.Main; + assert.deepEqual([...main.states].sort(), [ + "Nothing chosen", + "Upload failed", + "Uploaded", + "Uploading.Checking the file", + "Uploading.Sending the file", + ]); + assert.deepEqual([...main.initial], ["Nothing chosen"]); + assert.deepEqual(edges(main), [ + "Nothing chosen -> Uploading.Sending the file : you choose a file", + "Upload failed -> Uploading.Sending the file : you retry", + "Uploading.Checking the file -> Nothing chosen : you cancel", + "Uploading.Checking the file -> Upload failed : the network drops", + "Uploading.Checking the file -> Uploaded : the check passes", + "Uploading.Sending the file -> Nothing chosen : you cancel", + "Uploading.Sending the file -> Upload failed : the network drops", + "Uploading.Sending the file -> Uploading.Checking the file : the last byte arrives", + ]); +}); + +test("the deepest source wins when a composite and a state inside it share a label", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + state Form { + [*] --> Typing + Typing --> Confirm : you press Escape + state Confirm + } + [*] --> Form + Form --> Closed : you press Escape + Confirm --> Closed : you press Escape`); + assert.deepEqual(edges(regions.Main), [ + "Form.Confirm -> Closed : you press Escape", + "Form.Typing -> Form.Confirm : you press Escape", + ]); +}); + +test("a top-level composite split by -- makes one region per part", () => { + const { regions, nevers } = parseMermaid(fixture("sync.mmd")); + assert.deepEqual(Object.keys(regions), ["Connection", "Sync badge"]); + assert.deepEqual(edges(regions["Sync badge"]), [ + "Synced -> Waiting to sync : the network drops", + "Synced -> Waiting to sync : you edit a note", + "Waiting to sync -> Synced : the server confirms", + ]); + assert.deepEqual(nevers, [ + { + name: "NeverSyncedWhileOffline", + states: ["Offline", "Synced"], + sentence: 'You never see "Synced" while you are offline.', + }, + ]); +}); + +test("a rule's sentence is the comment lines right above it, and a state key ignores field order", () => { + const sentences = ruleSentences(`---- MODULE M ---- +\\* Not about a rule. + +\\* You never see "Saved" +\\* while the note is a draft. +NoFalseSaved == TRUE +Bare == TRUE +====`); + assert.deepEqual( + [...sentences], + [["NoFalseSaved", 'You never see "Saved" while the note is a draft.']], + ); + assert.equal(stateKey({ b: 1, a: { d: 2, c: [3] } }), stateKey({ a: { c: [3], d: 2 }, b: 1 })); +}); + +test("the legend is not part of the machine", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + state "Legend" as legend { + state "Works today" as legendWorks + } + [*] --> A + A --> B : go`); + assert.deepEqual([...regions.Main.states].sort(), ["A", "B"]); +}); + +test("the parser refuses charts it cannot read", () => { + assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n [*] --> B"), /unclosed/); + assert.throws(() => parseMermaid("stateDiagram-v2\n A ==> B"), /cannot read chart line/); + assert.throws( + () => parseMermaid("stateDiagram-v2\n state A {\n B --> C : go\n }\n [*] --> A"), + /no \[\*\] --> start/, + ); + assert.throws( + () => + parseMermaid( + "stateDiagram-v2\n state A {\n state B {\n [*] --> C\n --\n [*] --> D\n }\n [*] --> B\n }\n [*] --> A", + ), + /below the top level/, + ); + assert.throws( + () => parseMermaid("stateDiagram-v2\n [*] --> A\n A --> [*] : you close the app"), + /final state/, + ); + assert.throws( + () => + parseMermaid( + 'stateDiagram-v2\n state "Something went wrong" as UploadError\n state "Something went wrong" as SyncError\n [*] --> UploadError\n UploadError --> SyncError : go', + ), + /two states have the caption "Something went wrong"/, + ); +}); + +test("the parser reads class shorthand, repeated descriptions, and states first named inside a composite", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + classDef wrong fill:#fdd + N : Nothing chosen + N : press Upload to start + [*] --> N + N:::wrong --> Done : you finish + Done --> Moving:::wrong + state A { + [*] --> X + X --> Y : go + } + Moving --> A : start + Y --> Z : next + A --> N : cancel`); + assert.deepEqual([...regions.Main.states].sort(), [ + "A.X", + "A.Y", + "Done", + "Moving", + "Nothing chosen", + "Z", + ]); + assert.deepEqual(edges(regions.Main), [ + "A.X -> A.Y : go", + "A.X -> Nothing chosen : cancel", + "A.Y -> Nothing chosen : cancel", + "A.Y -> Z : next", + "Done -> Moving : ", + "Moving -> A.X : start", + "Nothing chosen -> Done : you finish", + ]); +}); + +test("TLC values, states, graphs, and traces parse", () => { + assert.deepEqual(parseValue('[a |-> <<1, "x">>, b |-> {TRUE}]'), { a: [1, "x"], b: [true] }); + assert.deepEqual(parseValue('("p" :> 1 @@ "q" :> 2)'), { p: 1, q: 2 }); + const graph = parseDot(`strict digraph DiskGraph { +1 [label="/\\\\ x = 0",style = filled] +2 [label="/\\\\ x = 1"] +1 -> 2 [label="Inc",color="black",fontcolor="black"]; +1 -> 2 [label="Inc",color="black",fontcolor="black"]; +2 -> 2 [label="Stay"]; +}`); + assert.equal(graph.nodes.get("1").init, true); + assert.deepEqual(graph.nodes.get("2").vars, { x: 1 }); + assert.deepEqual(graph.edges, [{ from: "1", to: "2", action: "Inc" }]); + assert.deepEqual(graph.loops, [{ from: "2", to: "2", action: "Stay" }]); + assert.throws( + () => + parseDot( + 'strict digraph DiskGraph {\n1 [label="/\\\\ x = 0",style = filled]\n2 [label="/\\\\ x = 1"]\n1 -> 2;\n}', + ), + /no action names/, + ); + const [trace] = parseTraces(`Error: Invariant Small is violated. +Error: The behavior up to this point is: +State 1: +x = 0 + +State 2: +x = 1 +`); + assert.equal(trace.invariant, "Small"); + assert.deepEqual(trace.steps, [ + { action: null, vars: { x: 0 } }, + { action: "Inc", vars: { x: 1 } }, + ]); +}); + +const done = "Model checking completed. No error has been found.\n"; +const evaluationError = `Error: Invariant NoLost is violated. +Error: The behavior up to this point is: +State 1: +s = "Idle" + +State 2: +s = "Lost" + +Error: TLC threw an unexpected exception. +This was probably caused by an error in the spec or model. + +Error: The behavior up to this point is: +State 1: +s = "Idle" + +State 2: +s = "Saving" + +3 states generated, 3 distinct states found, 0 states left on queue. +`; + +test("a rule broken by a start state is a counterexample of one state", () => { + const text = `Error: Invariant NeverSyncedWhileOffline is violated by the initial state: +/\\ badge = "Synced" +/\\ conn = "Offline" + +${done}`; + assert.doesNotThrow(() => checkTlcOutput(text, "tlc.out")); + assert.deepEqual(parseTraces(text), [ + { + invariant: "NeverSyncedWhileOffline", + steps: [{ action: null, vars: { badge: "Synced", conn: "Offline" } }], + }, + ]); +}); + +test("TLC output from a run that failed or stopped early is refused", () => { + assert.doesNotThrow(() => + checkTlcOutput( + `Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n\n${done}`, + "ok.out", + ), + ); + assert.throws( + () => + checkTlcOutput( + "Error: The invariant Nope specified in the configuration file\nis not defined in the specification.\n", + "cfg.out", + ), + /cfg\.out: TLC stopped on an error that is not a broken rule/, + ); + assert.throws( + () => checkTlcOutput("Error: Parsing or semantic analysis failed.\n", "parse.out"), + /not a broken rule/, + ); + assert.throws( + () => checkTlcOutput(evaluationError, "eval.out"), + /TLC threw an unexpected exception/, + ); + assert.throws( + () => + checkTlcOutput( + "Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n", + "stopped.out", + ), + /did not finish/, + ); + assert.deepEqual( + parseTraces(evaluationError).map((t) => t.invariant), + ["NoLost", null], + ); +}); + +test("a step that changes nothing keeps a state unsettled, and a quote in a reader label survives the chart", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "save.dot"), + `strict digraph DiskGraph { +1 [label="/\\\\ s = \\"Idle\\"",style = filled] +2 [label="/\\\\ s = \\"Saving\\""] +1 -> 2 [label="Tap"]; +2 -> 2 [label="Retry"]; +}`, + ); + writeFileSync( + join(dir, "save.map.mjs"), + `export const readerActions = ["Tap"]; +export const state = (v, facts) => (v.s === "Idle" ? 'Badge says "Synced"' : facts.settled ? "Saved" : "Saving"); +export const step = (action) => ({ Tap: "you tap Save" })[action] ?? ""; +`, + ); + const r = node( + "tlc-to-chart.mjs", + ["--dot", "save.dot", "--map", "save.map.mjs", "--highlight", "0", "--out", "chart.mmd"], + dir, + ); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(edges(parseMermaid(readFileSync(join(dir, "chart.mmd"), "utf8")).regions.Main), [ + 'Badge says "Synced" -> Saving : you tap Save', + ]); +}); + +test("chart-to-tla writes one action per label and one invariant per never line", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const r = node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); + assert.match(spec, /^VARIABLES connection, syncBadge$/m); + assert.match( + spec, + /^TheNetworkDrops ==\n {4}\/\\ \(connection \\in \{"Online"\} \\\/ syncBadge \\in \{"Synced"\}\)$/m, + ); + assert.match( + spec, + /^NeverSyncedWhileOffline == ~\(connection = "Offline" \/\\ syncBadge = "Synced"\)$/m, + ); + assert.equal( + readFileSync(join(dir, "Sync.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n", + ); + assert.match(r.stderr, /rules TypeOK NeverSyncedWhileOffline/); +}); + +test("chart-to-tla refuses a never line that names no state", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n", + ); + const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /names "C", which is not a state/); +}); + +test("a never line names a state exactly, or every state inside a composite", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + `stateDiagram-v2 + state "Saving" as S + state "Saving..." as S2 + state Busy { + [*] --> Checking + Checking --> Sending : the check passes + } + [*] --> S + S --> S2 : you wait + S2 --> Busy : you send + Busy --> S : you cancel + %% never NoSaving: Saving -- You never see Saving. + %% never NoBusy: Busy -- You are never busy. +`, + ); + const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "C.tla"), "utf8"); + assert.match(spec, /^NoSaving == ~\(main = "Saving"\)$/m); + assert.match(spec, /^NoBusy == ~\(main \\in \{"Busy\.Checking", "Busy\.Sending"\}\)$/m); + assert.match( + readFileSync(join(dir, "C.map.mjs"), "utf8"), + /^export const readerActions = \[\];$/m, + ); +}); + +const door = `stateDiagram-v2 + state Door { + [*] --> Locked + Locked --> Unlocked : tap unlock + Unlocked --> Locked : tap lock + -- + [*] --> Closed + Closed --> Open : open the door + Open --> Closed : close the door + } + [*] --> Door`; +const flatDoor = (extra = []) => + [ + "stateDiagram-v2", + ' state "Locked | Closed" as L_C', + ' state "Locked | Open" as L_O', + ' state "Unlocked | Closed" as U_C', + ' state "Unlocked | Open" as U_O', + " [*] --> L_C", + " L_C --> U_C : tap unlock", + " U_C --> L_C : tap lock", + " L_O --> U_O : tap unlock", + " L_C --> L_O : open the door", + " L_O --> L_C : close the door", + " U_C --> U_O : open the door", + " U_O --> U_C : close the door", + ...extra, + ].join("\n"); + +test("compare reads parallel regions as one machine, so a guard across regions shows", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "agreed.mmd"), door); + writeFileSync(join(dir, "guarded.mmd"), flatDoor()); + writeFileSync(join(dir, "free.mmd"), flatDoor([" U_O --> L_O : tap lock"])); + const differ = node("compare-charts.mjs", ["agreed.mmd", "guarded.mmd"], dir); + assert.equal(differ.status, 1); + assert.match( + differ.stdout, + /transitions only in EXPECTED[^\n]*:\n {2}Unlocked \| Open --> Locked \| Open : tap lock\n1 difference\(s\)/, + ); + const same = node("compare-charts.mjs", ["agreed.mmd", "free.mmd"], dir); + assert.equal(same.stdout.trim(), "same", same.stdout); +}); + +const lamp = `stateDiagram-v2 + state "Lamp" as lamp { + state "Power" as power { + [*] --> Off + Off --> On : flip the switch + On --> Off : flip the switch + } + -- + state "Bulb" as bulb { + [*] --> Dark + Dark --> Lit : warm up [when the
power is on] + %% when On + Lit --> Dark : cool down [when the power is off] + %% when not On + } + } + %% never LitWhileOff: Off & Lit -- The bulb is never lit while the power is off. +`; + +test("a %% when line guards a step on the other region's states, and [words] are only its text", () => { + const { regions } = parseMermaid(lamp); + assert.deepEqual(regions.Bulb.transitions, [ + { from: "Dark", to: "Lit", label: "warm up", guard: { not: false, states: { Power: ["On"] } } }, + { + from: "Lit", + to: "Dark", + label: "cool down", + guard: { not: true, states: { Power: ["On"] } }, + }, + ]); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "lamp.mmd"), lamp); + writeFileSync( + join(dir, "flat.mmd"), + [ + "stateDiagram-v2", + " [*] --> OD", + ' state "Off | Dark" as OD', + ' state "On | Dark" as ND', + ' state "On | Lit" as NL', + ' state "Off | Lit" as OL', + " OD --> ND : flip the switch", + " ND --> OD : flip the switch", + " ND --> NL : warm up", + " NL --> OL : flip the switch", + " OL --> NL : flip the switch", + " OL --> OD : cool down", + ].join("\n"), + ); + const same = node("compare-charts.mjs", ["lamp.mmd", "flat.mmd"], dir); + assert.equal(same.stdout, "same\n", same.stderr); + const tla = node("chart-to-tla.mjs", ["lamp.mmd", "--module", "Lamp"], dir); + assert.equal(tla.status, 0, tla.stderr); + const spec = readFileSync(join(dir, "Lamp.tla"), "utf8"); + assert.match(spec, /\(bulb = "Dark" \/\\ power = "On" \/\\ bulb' = "Lit"\)/); + assert.match(spec, /\(bulb = "Lit" \/\\ ~\(power = "On"\) \/\\ bulb' = "Dark"\)/); + + for (const [from, to, why] of [ + [ + " %% when On\n", + " state Glow\n %% when On\n", + /must come right after a transition/, + ], + [ + "warm up [when the
power is on]", + "warm up", + /must end its label with the guard's words in \[ \]/, + ], + [" %% when On\n", " %% when Nope\n", /names Nope, which is not a state id/], + [" %% when On\n", " %% when Lit\n", /names Lit, which is in the step's own region/], + ]) + assert.throws(() => parseMermaid(lamp.replace(from, to)), why); + assert.throws( + () => + parseMermaid( + "stateDiagram-v2\n [*] --> A\n A --> B : go [when C]\n %% when C\n B --> C : on\n", + ), + /the chart has none/, + ); + assert.deepEqual( + edges(parseMermaid("stateDiagram-v2\n [*] --> A\n A --> B : go [user away]\n").regions.Main), + ["A -> B : go"], + ); +}); + +const switchLamp = `stateDiagram-v2 + state "Lamp" as lamp { + state "Power" as power { + [*] --> Off + Off --> On : flip the switch + On --> Off : flip the switch + } + -- + state "Bulb" as bulb { + [*] --> Dark + Dark --> Lit : flip the switch [when the power is off] + %% when Off + Lit --> Dark : flip the switch + } + } +`; + +test("a label with a guarded and an unguarded move in one region writes both moves", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "switch.mmd"), switchLamp); + const r = node("chart-to-tla.mjs", ["switch.mmd", "--module", "Switch"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "Switch.tla"), "utf8"); + assert.equal( + spec.slice(spec.indexOf("\\* flip the switch"), spec.indexOf("\nNext ==")), + `\\* flip the switch +FlipTheSwitch == + /\\ (power \\in {"Off", "On"} \\/ (bulb = "Dark" /\\ power = "Off") \\/ (bulb = "Lit")) + /\\ \\/ (power = "Off" /\\ power' = "On") + \\/ (power = "On" /\\ power' = "Off") + \\/ (power \\notin {"Off", "On"} /\\ UNCHANGED power) + /\\ \\/ (bulb = "Dark" /\\ power = "Off" /\\ bulb' = "Lit") + \\/ (bulb = "Lit" /\\ bulb' = "Dark") + \\/ (~((bulb = "Dark" /\\ power = "Off") \\/ (bulb = "Lit")) /\\ UNCHANGED bulb) +`, + ); +}); + +test("compare reads a one-region chart as the machine its steps reach, without steps that change nothing", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const drawn = + "stateDiagram-v2\n [*] --> Idle\n Idle --> Busy : start\n Busy --> Idle : finish\n"; + writeFileSync(join(dir, "drawn.mmd"), drawn); + writeFileSync(join(dir, "loop.mmd"), `${drawn} Idle --> Idle : refresh\n`); + writeFileSync(join(dir, "orphan.mmd"), `${drawn} Lost --> Idle : come back\n`); + const same = node("compare-charts.mjs", ["loop.mmd", "drawn.mmd"], dir); + assert.equal(same.status, 0); + assert.equal(same.stdout, "same\n"); + const orphan = node("compare-charts.mjs", ["orphan.mmd", "drawn.mmd"], dir); + assert.equal(orphan.status, 1); + assert.equal( + orphan.stdout, + "states the agreed chart's own steps never reach:\n Lost\n1 difference(s)\n", + ); +}); + +test("a -- outside a composite is refused, not read as one region", () => { + const split = + "stateDiagram-v2\n [*] --> Online\n Online --> Offline : the network drops\n --\n [*] --> Synced\n Synced --> Pending : you edit a note\n"; + assert.throws(() => parseMermaid(split), /-- outside a composite/); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "split.mmd"), split); + writeFileSync(join(dir, "door.mmd"), door); + const tla = node("chart-to-tla.mjs", ["split.mmd", "--module", "Split"], dir); + assert.notEqual(tla.status, 0); + assert.match(tla.stderr, /-- outside a composite/); + for (const pair of [ + ["split.mmd", "split.mmd"], + ["door.mmd", "split.mmd"], + ["split.mmd", "door.mmd"], + ]) { + const r = node("compare-charts.mjs", pair, dir); + assert.equal(r.status, 2, pair.join(" ")); + assert.match(r.stderr, /^split\.mmd: the chart has -- outside a composite/); + } +}); + +test("compare refuses a chart it cannot read, or an ACTUAL chart that is not drawn for comparison", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "agreed.mmd"), door); + for (const pair of [ + ["agreed.mmd", "missing.mmd"], + ["missing.mmd", "agreed.mmd"], + ]) { + const r = node("compare-charts.mjs", pair, dir); + assert.equal(r.status, 2, pair.join(" ")); + assert.match(r.stderr, /^missing\.mmd: ENOENT/); + } + for (const [extra, why] of [ + [" U_O --> L_O : 1. tap lock", /--highlight 0/], + [ + " %% second model (against): the spec", + "actual.mmd: it has a second model; draw ACTUAL without --fixed-dot or --against-dot\n", + ], + [ + ' state "Locked" as g1 {\n state "Closed" as L_C2\n }', + "actual.mmd: it has boxes; draw ACTUAL with --flat\n", + ], + ]) { + writeFileSync(join(dir, "actual.mmd"), flatDoor([extra])); + const r = node("compare-charts.mjs", ["agreed.mmd", "actual.mmd"], dir); + assert.equal(r.status, 2, extra); + if (typeof why === "string") assert.equal(r.stderr, why); + else assert.match(r.stderr, why); + } +}); + +test("an edge lists every step, writes shared leading words once, and the chart renders at its natural width", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const nodes = [ + ["Home", 1], + ["Home", 2], + ["Home", 3], + ["Away", 1], + ["Away", 2], + ["Done", 1], + ].map( + ([s, n], i) => + `${i + 1} [label="/\\\\ s = \\"${s}\\"\\n/\\\\ n = ${n}"${i ? "" : ",style = filled"}]`, + ); + const steps = [ + "1 -> 4 TapPay", + "2 -> 5 TapBack", + "3 -> 4 TapHome", + "4 -> 1 OpenThread", + "5 -> 2 OpenApp", + "4 -> 6 Finish", + ]; + writeFileSync( + join(dir, "nav.dot"), + `strict digraph DiskGraph {\n${nodes.join("\n")}\n${steps.map((e) => e.replace(/ (\w+)$/, ' [label="$1"];')).join("\n")}\n}\n`, + ); + writeFileSync( + join(dir, "nav.map.mjs"), + `export const state = (v) => v.s; +export const step = (a) => ({ TapPay: "you tap Pay", TapBack: "you tap Back", TapHome: "you tap Home", OpenThread: "you open the thread", OpenApp: "you open the phone app", Finish: "" })[a]; +`, + ); + writeFileSync( + join(dir, "agreed.mmd"), + `stateDiagram-v2 + [*] --> Home + Home --> Away : you tap Pay + Home --> Away : you tap Back + Home --> Away : you tap Home + Away --> Home : you open the thread + Away --> Home : you open the phone app + Away --> Done +`, + ); + const draw = (...extra) => { + const r = node( + "tlc-to-chart.mjs", + [ + "--dot", + "nav.dot", + "--map", + "nav.map.mjs", + "--highlight", + "0", + "--out", + "chart.mmd", + ...extra, + ], + dir, + ); + assert.equal(r.status, 0, r.stderr); + return readFileSync(join(dir, "chart.mmd"), "utf8"); + }; + const chart = draw(); + assert.ok( + chart.startsWith( + "---\nconfig:\n markdownAutoWrap: false\n state:\n useMaxWidth: false\n---\nstateDiagram-v2\n", + ), + chart, + ); + const shortened = [ + "Away -> Done : ", + "Away -> Home : you open the thread or the phone app", + "Home -> Away : you tap Pay, Back, or Home", + ]; + assert.deepEqual(edges(parseMermaid(chart).regions.Main), shortened); + const crlf = chart.replace("---\n", "--- \n").replaceAll("\n", "\r\n"); + assert.deepEqual(edges(parseMermaid(crlf).regions.Main), shortened); + const refused = node("compare-charts.mjs", ["agreed.mmd", "chart.mmd"], dir); + assert.equal(refused.status, 2); + assert.match(refused.stderr, /draw ACTUAL with --full-labels/); + const full = draw("--full-labels"); + assert.match(full, /^stateDiagram-v2\n {2}%% labels in full\n/m); + assert.deepEqual(edges(parseMermaid(full).regions.Main), [ + "Away -> Done : ", + "Away -> Home : you open the thread or you open the phone app", + "Home -> Away : you tap Pay or you tap Back or you tap Home", + ]); + assert.equal(node("compare-charts.mjs", ["agreed.mmd", "chart.mmd"], dir).stdout, "same\n"); +}); + +const NO_TLC = `Cannot find TLC. Download tla2tools.jar from a TLA+ release: + https://github.com/tlaplus/tlaplus/releases +Then set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later). +`; +const FROM_SPEC_USAGE = + "node statechart.mjs from-spec SPEC.tla [--fixed FIXED.tla | --against AGREED.tla] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]"; +const FROM_CHART_USAGE = + "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]"; +const noTlc = { TLA2TOOLS: "", JAVA: "" }; + +test("statechart.mjs writes the spec next to the chart, keeps it on the next run, and stops without TLC", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "notes-app.mmd"), fixture("sync.mmd")); + const first = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, noTlc); + assert.equal(first.status, 2); + assert.equal( + first.stdout, + "Wrote NotesApp.tla, NotesApp.cfg, and NotesApp.map.mjs from notes-app.mmd.\n", + ); + assert.equal(first.stderr, NO_TLC); + assert.equal( + readFileSync(join(dir, "NotesApp.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n", + ); + const again = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, noTlc); + assert.equal( + again.stdout, + "Kept NotesApp.tla. Delete it to write the spec again from notes-app.mmd.\n", + ); + const spec = node("statechart.mjs", ["from-spec", "NotesApp.tla"], dir, noTlc); + assert.equal(spec.status, 2); + assert.equal(spec.stderr, NO_TLC); + const missingJar = node("statechart.mjs", ["from-spec", "NotesApp.tla"], dir, { + TLA2TOOLS: "/nonexistent/tla2tools.jar", + }); + assert.equal(missingJar.status, 2); + assert.equal( + missingJar.stderr, + "TLA2TOOLS is /nonexistent/tla2tools.jar, which does not exist.\n", + ); +}); + +test("statechart.mjs writes no output folder when it refuses the input", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "tla2tools.jar"), ""); + writeFileSync(join(dir, "Pay.tla"), "---- MODULE Pay ----\n====\n"); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT Paid\n"); + writeFileSync(join(dir, "cart.mmd"), "stateDiagram-v2\n [*] --> Cart\n Cart --> Paid : pay\n"); + for (const args of [ + ["from-spec", "Pya.tla"], + ["from-spec", "Pay.tla", "--expect-broken", "Unpaid"], + ["from-spec", "Pay.tla", "--fixed", "PayFixd.tla"], + ["from-chart", "cart.mmd", "--expect-broken", "Unpaid"], + ]) { + const r = node("statechart.mjs", args, dir, { TLA2TOOLS: join(dir, "tla2tools.jar") }); + assert.equal(r.status, 2, r.stderr); + } + assert.deepEqual( + readdirSync(dir).filter((f) => f.endsWith("-statechart")), + [], + ); +}); + +test("statechart.mjs does not use an output folder that has files that it did not write", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "tla2tools.jar"), ""); + writeFileSync(join(dir, "Pay.tla"), "---- MODULE Pay ----\n====\n"); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\n"); + const env = { TLA2TOOLS: join(dir, "tla2tools.jar") }; + const cwd = node("statechart.mjs", ["from-spec", "Pay.tla", "--out", "."], dir, env); + assert.equal(cwd.status, 2); + assert.equal( + cwd.stderr, + ". has 3 files that this command did not write, such as Pay.cfg. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + ); + const out = join(dir, "Pay-statechart"); + mkdirSync(join(out, "tlc"), { recursive: true }); + writeFileSync(join(out, "chart.mmd"), "kept\n"); + writeFileSync(join(out, ".DS_Store"), ""); + writeFileSync(join(out, "notes.md"), "mine\n"); + const notes = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, env); + assert.equal(notes.status, 2); + assert.equal( + notes.stderr, + "Pay-statechart has a file that this command did not write: notes.md. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + ); + assert.deepEqual(readdirSync(dir).sort(), [ + "Pay-statechart", + "Pay.cfg", + "Pay.tla", + "tla2tools.jar", + ]); + assert.deepEqual(readdirSync(out).sort(), [".DS_Store", "chart.mmd", "notes.md", "tlc"]); + assert.equal(readFileSync(join(out, "chart.mmd"), "utf8"), "kept\n"); +}); + +test("statechart.mjs exits 2 and names a helper script that cannot start", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "notes-app.mmd"), fixture("sync.mmd")); + writeFileSync(join(dir, "no-node.cjs"), 'process.execPath = "/nonexistent/node";\n'); + const r = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, { + NODE_OPTIONS: `--require ${join(dir, "no-node.cjs")}`, + }); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /chart-to-tla\.mjs/); +}); + +test("statechart.mjs names the chart it refuses and the argument it does not know", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n", + ); + const refused = node("statechart.mjs", ["from-chart", "c.mmd"], dir); + assert.equal(refused.status, 2); + assert.equal(refused.stdout, ""); + assert.equal(refused.stderr, 'c.mmd: never line names "C", which is not a state in the chart\n'); + const unknown = node("statechart.mjs", ["from-chart", "c.mmd", "--fixed", "CFixed.tla"], dir); + assert.equal(unknown.status, 2); + assert.equal( + unknown.stderr, + "unknown argument --fixed. Run node statechart.mjs from-chart --help for the flags, the output, and the exit codes.\n", + ); + const sameName = node("statechart.mjs", ["from-spec", "C.tla", "--fixed", "fix/C.tla"], dir); + assert.equal(sameName.status, 2); + assert.equal( + sameName.stderr, + "fix/C.tla needs its own file and module name, such as CFixed.tla.\n", + ); + const sameAgreed = node("statechart.mjs", ["from-spec", "C.tla", "--against", "a/C.tla"], dir); + assert.equal( + sameAgreed.stderr, + "a/C.tla needs its own file and module name, such as CAgreed.tla.\n", + ); + for (const [extra, stderr] of [ + [ + ["--fixed", "F.tla", "--against", "A.tla"], + "use --fixed or --against, not both. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n", + ], + [ + ["--label", "the new build"], + "--label names the --fixed or --against model; it needs one of them. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n", + ], + ]) { + const r = node("statechart.mjs", ["from-spec", "C.tla", ...extra], dir); + assert.equal(r.status, 2, extra.join(" ")); + assert.equal(r.stderr, stderr); + } + const valueless = node("statechart.mjs", ["from-chart", "c.mmd", "--out"], dir); + assert.equal(valueless.status, 2); + assert.equal( + valueless.stderr, + "--out needs a value. Run node statechart.mjs from-chart --help for the flags, the output, and the exit codes.\n", + ); + const bare = node("statechart.mjs", [], dir); + assert.equal(bare.status, 2); + assert.equal( + bare.stderr, + `Usage: ${FROM_SPEC_USAGE} + ${FROM_CHART_USAGE} +Run node statechart.mjs --help for the flags, the output, and the exit codes. +`, + ); +}); + +test("every script prints its help with -h or --help, and points at it for an argument it does not know", () => { + const help = { + "statechart.mjs": [[], `Usage: ${FROM_SPEC_USAGE}`], + "statechart.mjs from-spec": [["from-spec"], `Usage: ${FROM_SPEC_USAGE}`], + "statechart.mjs from-chart": [["from-chart", "notes-app.mmd"], `Usage: ${FROM_CHART_USAGE}`], + "chart-to-tla.mjs": [[], "Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"], + "tlc-to-chart.mjs": [[], "Usage: node tlc-to-chart.mjs --dot FILE --map FILE [options]"], + "compare-charts.mjs": [[], "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"], + }; + for (const [name, [before, usage]] of Object.entries(help)) { + const script = name.split(" ")[0]; + for (const flag of ["-h", "--help"]) { + const r = node(script, [...before, flag], here); + assert.equal(r.status, 0, `${name} ${flag}`); + assert.equal(r.stderr, ""); + assert.equal(r.stdout.split("\n")[0], usage); + assert.match(r.stdout, /\nExit codes\n {2}0 .*\n(.*\n)* {2}1 .*\n(.*\n)* {2}2 /); + } + } + const top = node("statechart.mjs", ["--help"], here).stdout.split("\n"); + assert.equal(top[1], ` ${FROM_CHART_USAGE}`); + assert.equal(top[2], " node statechart.mjs [from-spec|from-chart] --help"); + for (const flag of [ + "--fixed FIXED.tla", + "--label TEXT", + "--map FILE", + "--module NAME", + "--title TEXT", + "--out DIR", + "--png", + "-h, --help", + ]) + assert.ok( + top.some((l) => l.startsWith(` ${flag} `)), + flag, + ); + for (const [script, args, stderr] of [ + [ + "statechart.mjs", + ["from-spec", "--nope"], + "unknown argument --nope. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n", + ], + [ + "chart-to-tla.mjs", + ["--nope"], + "unknown argument --nope. Run node chart-to-tla.mjs --help for the flags.\n", + ], + [ + "tlc-to-chart.mjs", + ["--nope"], + "unknown argument --nope. Run node tlc-to-chart.mjs --help for the flags.\n", + ], + [ + "tlc-to-chart.mjs", + ["--dot", "a.dot", "--map", "a.map.mjs", "--regions"], + "--regions draws the whole customer chart: use --highlight 0, without --flat or --path-only. Run node tlc-to-chart.mjs --help for the flags.\n", + ], + [ + "tlc-to-chart.mjs", + ["--dot", "a.dot", "--map", "a.map.mjs", "--proof", "p.mmd"], + "--proof writes the regions proof; it needs --regions. Run node tlc-to-chart.mjs --help for the flags.\n", + ], + [ + "compare-charts.mjs", + ["--nope"], + "unknown argument --nope. Run node compare-charts.mjs --help for the flags.\n", + ], + ]) { + const r = node(script, args, here); + assert.equal(r.status, 2, script); + assert.equal(r.stdout, ""); + assert.equal(r.stderr, stderr); + } +}); + +const tools = (() => { + try { + return findTlc(); + } catch { + return null; + } +})(); +const needsTlc = { skip: tools ? false : "cannot find TLC" }; + +function tlc(dir, name) { + const r = spawnSync( + tools.java, + [ + "-cp", + tools.jar, + "tlc2.TLC", + "-metadir", + mkdtempSync(join(tmpdir(), "tlc-")), + "-deadlock", + "-continue", + "-dump", + "dot,actionlabels", + `${name}.dot`, + "-config", + `${name}.cfg`, + `${name}.tla`, + ], + { cwd: dir, encoding: "utf8" }, + ); + writeFileSync(join(dir, `${name}.out`), r.stdout); + return r; +} + +test("from-chart checks the agreed chart and draws it back unchanged", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "upload.mmd"), fixture("upload.mmd")); + const r = node("statechart.mjs", ["from-chart", "upload.mmd"], dir); + assert.equal(r.status, 0, r.stderr); + assert.equal( + r.stdout, + `Wrote Upload.tla, Upload.cfg, and Upload.map.mjs from upload.mmd. +TLC on Upload.tla: + TypeOK holds. + +Chart: 5 states and 8 transitions from 5 model states. +5 work, 0 break a rule. + +No rule is broken. + +The spec draws upload.mmd back unchanged. + +Wrote chart.mmd, stories.md, summary.json in Upload-statechart. +`, + ); + assert.equal(r.stderr, ""); +}); + +test("TLC reads a label with a guarded and an unguarded move in one region", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "switch.mmd"), switchLamp); + const r = node("statechart.mjs", ["from-chart", "switch.mmd"], dir); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /\nThe spec draws switch\.mmd back unchanged\.\n/); +}); + +const syncStory = `## 1. Offline | Synced + +Rule broken: You never see "Synced" while you are offline. +This is the only way to get here in the model. + +0. Start: Online | Synced +1. The network drops. Now: Offline | Waiting to sync +2. The server confirms. Now: Offline | Synced +`; + +test("from-chart finds the design bug, and from-spec with the fix marks it fixed", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const today = node("statechart.mjs", ["from-chart", "sync.mmd"], dir); + assert.equal(today.status, 1, today.stderr); + assert.equal( + today.stdout, + `Wrote Sync.tla, Sync.cfg, and Sync.map.mjs from sync.mmd. +TLC on Sync.tla: + TypeOK holds. + NeverSyncedWhileOffline is broken. + +Chart: 4 states and 8 transitions from 4 model states. +3 work, 1 breaks a rule. + +${syncStory} +The spec draws sync.mmd back unchanged. + +Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart. +`, + ); + assert.match( + readFileSync(join(dir, "Sync-statechart", "bug-1.mmd"), "utf8"), + /: 2\. the server confirms\n/, + ); + + const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); + const guarded = spec + .replace("TheServerConfirms ==\n", 'TheServerConfirms ==\n /\\ connection = "Online"\n') + .replace("MODULE Sync", "MODULE SyncFixed"); + assert.notEqual(guarded, spec); + writeFileSync(join(dir, "SyncFixed.tla"), guarded); + const fixed = node("statechart.mjs", ["from-spec", "Sync.tla", "--fixed", "SyncFixed.tla"], dir); + assert.equal(fixed.status, 1, fixed.stderr); + assert.equal( + fixed.stdout, + `TLC on Sync.tla: + TypeOK holds. + NeverSyncedWhileOffline is broken. +TLC on SyncFixed.tla: + TypeOK holds. + NeverSyncedWhileOffline holds. + +Chart: 4 states and 9 transitions from 4 model states. +3 work today, 0 still wrong with the fix, 1 fixed or new with the fix. + +${syncStory} +With the fix, step 2 (the server confirms) cannot happen, so you stay at: Offline | Waiting to sync. + +Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart. +`, + ); + const summary = JSON.parse(readFileSync(join(dir, "Sync-statechart", "summary.json"), "utf8")); + assert.deepEqual(summary.chartStatus, { works: 3, wrong: 0, fixed: 1 }); + assert.equal(summary.classes[0].fix, "Offline | Waiting to sync"); + + const map = readFileSync(join(dir, "Sync.map.mjs"), "utf8"); + writeFileSync(join(dir, "Sync.map.mjs"), `${map}export const group = () => "Your notes";\n`); + const grouped = node( + "statechart.mjs", + ["from-spec", "Sync.tla", "--fixed", "SyncFixed.tla"], + dir, + ); + assert.equal(grouped.status, 1, grouped.stderr); + const lines = readFileSync(join(dir, "Sync-statechart", "chart.mmd"), "utf8").split("\n"); + assert.deepEqual( + lines.filter((l) => /^ {2}(classDef|class) |legend/.test(l)), + [ + " classDef plainFixed fill:#f4f6f7,stroke:#1e8449,stroke-width:4px,color:#145a32", + " classDef group0 fill:#d6eaf8,stroke:#6b757c,color:#1b2631", + " classDef group0Fixed fill:#d6eaf8,stroke:#1e8449,stroke-width:4px,color:#145a32", + ' state "Legend" as legend {', + ' state "Your notes" as legendGroup0', + ' state "Fixed or new with the fix" as legendFixed', + " class legendGroup0 group0", + " class legendFixed plainFixed", + " class s0 group0Fixed", + " class s1 group0", + " class s2 group0", + " class s3 group0", + ], + ); + writeFileSync(join(dir, "Sync.map.mjs"), map); + + writeFileSync(join(dir, "Sync.tla"), guarded.replace("MODULE SyncFixed", "MODULE Sync")); + const coded = node("statechart.mjs", ["from-chart", "sync.mmd"], dir); + assert.equal(coded.status, 1, coded.stderr); + assert.equal( + coded.stdout, + `Kept Sync.tla. Delete it to write the spec again from sync.mmd. +TLC on Sync.tla: + TypeOK holds. + NeverSyncedWhileOffline holds. + +Chart: 3 states and 5 transitions from 3 model states. +3 work, 0 break a rule. + +No rule is broken. + +The spec does not draw sync.mmd: + states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them): + Offline | Synced + transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step): + Offline | Synced --> Offline | Waiting to sync : the network drops + Offline | Synced --> Offline | Waiting to sync : you edit a note + Offline | Synced --> Online | Synced : the network returns + Offline | Waiting to sync --> Offline | Synced : the server confirms + 5 difference(s) + +Wrote chart.mmd, stories.md, summary.json in Sync-statechart. +`, + ); + assert.deepEqual(readdirSync(join(dir, "Sync-statechart")).sort(), [ + "chart.mmd", + "stories.md", + "summary.json", + "tlc", + ]); +}); + +test( + "--png exits 2 unless every chart renders, even with --expect-broken, and keeps the files it wrote", + needsTlc, + () => { + const args = ["from-chart", "sync.mmd", "--expect-broken", "NeverSyncedWhileOffline", "--png"]; + const bin = mkdtempSync(join(tmpdir(), "fake-npx-")); + const launch = "Error: Failed to launch the browser process!"; + writeFileSync( + join(bin, "npx"), + `#!/bin/sh +while [ $# -gt 0 ]; do + case "$1" in -i) chart=$2 ;; -o) png=$2 ;; esac + shift +done +for c in $UNRENDERED; do + case "$chart" in */$c.mmd) echo "${launch}" >&2; exit 1 ;; esac +done +echo png > "$png" +`, + { mode: 0o755 }, + ); + for (const [unrendered, status, written] of [ + ["chart bug-1", 2, ["chart.mmd", "bug-1.mmd"]], + ["bug-1", 2, ["chart.mmd", "bug-1.mmd", "chart.png"]], + ["", 0, ["chart.mmd", "bug-1.mmd", "chart.png", "bug-1.png"]], + ]) { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const r = node("statechart.mjs", args, dir, { + PATH: `${bin}${delimiter}${process.env.PATH}`, + UNRENDERED: unrendered, + }); + assert.equal(r.status, status, `${unrendered}: ${r.stdout}${r.stderr}`); + assert.equal( + r.stderr, + unrendered + .split(" ") + .filter(Boolean) + .map((c) => `could not render Sync-statechart/${c}.mmd: ${launch}\n`) + .join(""), + ); + assert.match(r.stdout, /\nBroken as expected: NeverSyncedWhileOffline\.\n/); + assert.equal( + r.stdout.split("\n").at(-2), + `Wrote ${[...written, "stories.md", "summary.json"].join(", ")} in Sync-statechart.`, + ); + assert.deepEqual( + readdirSync(join(dir, "Sync-statechart")).sort(), + [...written, "stories.md", "summary.json", "tlc"].sort(), + ); + } + }, +); + +const pay = (name, charge) => `---- MODULE ${name} ---- +EXTENDS Naturals +VARIABLES screen, charges +vars == <> +Init == screen = "Cart" /\\ charges = 0 +Pay == screen = "Cart" /\\ screen' = "Paying" /\\ UNCHANGED charges +Charge == ${charge} +Refund == charges = 2 /\\ charges' = 1 /\\ screen' = "Refunded" +Next == Pay \\/ Charge \\/ Refund +Spec == Init /\\ [][Next]_vars +NoDoubleCharge == charges <= 1 +==== +`; +const payMap = `export const readerActions = ["Pay"]; +export const state = (v) => v.screen + ["", ", charged", ", charged twice"][v.charges]; +export const step = (action) => ({ Pay: "you pay", Charge: "the bank charges you", Refund: "the bank refunds you" })[action] ?? ""; +export const cause = () => "each charge request charges again"; +`; + +test( + "a state reached only after a bug takes the bug's color, and the fix edge leaves the state before the changed step", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const cfg = "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"; + writeFileSync( + join(dir, "Pay.tla"), + pay( + "Pay", + `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, + ), + ); + writeFileSync( + join(dir, "PayFixed.tla"), + pay( + "PayFixed", + `screen = "Paying" /\\ charges < 2 /\\ IF charges = 0 THEN charges' = 1 /\\ UNCHANGED screen ELSE screen' = "Paid" /\\ UNCHANGED charges`, + ), + ); + writeFileSync(join(dir, "Pay.cfg"), cfg); + writeFileSync(join(dir, "PayFixed.cfg"), cfg); + writeFileSync(join(dir, "pay.map.mjs"), payMap); + assert.match(tlc(dir, "Pay").stdout, /Invariant NoDoubleCharge is violated/); + assert.doesNotMatch(tlc(dir, "PayFixed").stdout, /is violated/); + + const statusOf = (chart) => { + const names = new Map( + [...chart.matchAll(/^ {2}state "([^"]+)" as (s\d+)$/gm)].map((m) => [ + m[2], + m[1].replace(//g, " "), + ]), + ); + return Object.fromEntries( + [...chart.matchAll(/^ {2}class (s\d+) (\w+)$/gm)].map((m) => [names.get(m[1]), m[2]]), + ); + }; + const args = [ + "--dot", + "Pay.dot", + "--map", + "pay.map.mjs", + "--traces", + "Pay.out", + "--highlight", + "0", + "--out", + "chart.mmd", + ]; + assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); + assert.deepEqual(statusOf(readFileSync(join(dir, "chart.mmd"), "utf8")), { + Cart: "works", + Paying: "works", + "Paying, charged": "works", + "Paying, charged twice": "wrong", + "Refunded, charged": "wrong", + }); + + const withFix = [...args, "--fixed-dot", "PayFixed.dot", "--fixed-traces", "PayFixed.out"]; + assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); + const chart = readFileSync(join(dir, "chart.mmd"), "utf8"); + assert.deepEqual(statusOf(chart), { + Cart: "works", + "Paid, charged": "fixed", + Paying: "works", + "Paying, charged": "works", + "Paying, charged twice": "fixed", + "Refunded, charged": "fixed", + }); + assert.deepEqual( + [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]), + ["Works today", "Fixed or new with the fix"], + ); + assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(fix\): the fix\n/m); + const fixEdges = parseMermaid(chart).regions.Main.transitions.filter((t) => + t.label.startsWith("with the fix, "), + ); + assert.deepEqual( + fixEdges.map((t) => `${t.from} --> ${t.to} : ${t.label}`), + ["Paying, charged --> Paid, charged : with the fix, the bank charges you"], + ); + }, +); + +test( + "from-spec writes a mapping to fill in, then draws each bug in every view the mapping has", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + pay( + "Pay", + `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, + ), + ); + writeFileSync( + join(dir, "Pay.cfg"), + "SPECIFICATION Spec\n\\* The reader's rule.\nINVARIANTS\n NoDoubleCharge\n", + ); + const first = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + assert.equal(first.status, 2); + assert.equal(first.stdout, "TLC on Pay.tla:\n NoDoubleCharge is broken.\n"); + assert.equal( + first.stderr, + "\nWrote Pay.map.mjs. Fill in its labels, then run this command again.\n", + ); + const scaffold = readFileSync(join(dir, "Pay.map.mjs"), "utf8"); + assert.match(scaffold, /^\/\/ Variables: screen, charges\.$/m); + assert.match( + scaffold, + /^const STEPS = \{\n\tCharge: "Charge",\n\tPay: "Pay",\n\tRefund: "Refund",\n\};$/m, + ); + assert.equal(existsSync(join(dir, "Pay-statechart")), false); + + writeFileSync( + join(dir, "Pay.map.mjs"), + `${payMap}export const detail = (v) => \`charges = \${v.charges}\`;\n`, + ); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + assert.equal(r.status, 1, r.stderr); + assert.equal( + r.stdout, + `TLC on Pay.tla: + NoDoubleCharge is broken. + +Chart: 5 states and 4 transitions from 5 model states. +3 work, 2 break a rule. + +## 1. Paying, charged twice + +Rule broken: \`NoDoubleCharge\` +Why: each charge request charges again. +This is the only way to get here in the model. + +0. Start: Cart +1. You pay. Now: Paying +2. The bank charges you. Now: Paying, charged +3. The bank charges you. Now: Paying, charged twice + +Wrote chart.mmd, bug-1.mmd, bug-1-developer.mmd, bug-1-layered.mmd, stories.md, summary.json in Pay-statechart. +`, + ); + assert.match( + readFileSync(join(dir, "Pay-statechart", "bug-1-layered.mmd"), "utf8"), + /state "Paying, charged twice" as g\d \{\n {4}state "charges = 2" as s\d\n/, + ); + assert.deepEqual(readdirSync(join(dir, "Pay-statechart")).sort(), [ + "bug-1-developer.mmd", + "bug-1-layered.mmd", + "bug-1.mmd", + "chart.mmd", + "stories.md", + "summary.json", + "tlc", + ]); + assert.equal( + readFileSync(join(dir, "Pay-statechart", "tlc", "Pay.cfg"), "utf8"), + "SPECIFICATION Spec\n", + ); + assert.equal( + readFileSync(join(dir, "Pay-statechart", "tlc", "Pay-NoDoubleCharge.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\nCONSTRAINT NoDoubleCharge\n", + ); + }, +); + +test("a rule broken by a start state marks that state wrong", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Start.tla"), + `---- MODULE Start ---- +VARIABLES conn, badge +vars == <> +Init == conn \\in {"Online", "Offline"} /\\ badge = "Synced" +Drop == conn = "Online" /\\ conn' = "Offline" /\\ badge' = "Waiting" +Next == Drop +Spec == Init /\\ [][Next]_vars +NeverSyncedWhileOffline == ~(conn = "Offline" /\\ badge = "Synced") +==== +`, + ); + writeFileSync(join(dir, "Start.cfg"), "SPECIFICATION Spec\nINVARIANT NeverSyncedWhileOffline\n"); + writeFileSync( + join(dir, "start.map.mjs"), + 'export const state = (v) => `${v.conn} | ${v.badge}`;\nexport const step = () => "the network drops";\n', + ); + assert.match(tlc(dir, "Start").stdout, /is violated by the initial state/); + const args = [ + "--dot", + "Start.dot", + "--map", + "start.map.mjs", + "--traces", + "Start.out", + "--summary", + "summary.json", + "--out", + "chart.mmd", + ]; + const r = node("tlc-to-chart.mjs", args, dir); + assert.equal(r.status, 0, r.stderr); + const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual( + summary.classes.map((c) => [c.end, c.readerSteps]), + [["Offline | Synced", 0]], + ); + assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 0 }); +}); + +const edit = (name, fixed) => `---- MODULE ${name} ---- +VARIABLES s, flashed +vars == <> +Init == s = "Idle" /\\ flashed = FALSE +Tap == s = "Idle" /\\ s' = "Saving" /\\ UNCHANGED flashed +Lose == s = "Saving" /\\ s' = "Lost edit" /\\ UNCHANGED flashed +Flash == s = "Saving" /\\ s' = "Flash" /\\ flashed' = ${fixed ? "TRUE" : "FALSE"} +Back == s \\in {"Lost edit", "Flash"} /\\ s' = "Idle" /\\ flashed' = FALSE +Next == Tap \\/ Flash \\/ Back${fixed ? "" : " \\/ Lose"} +Spec == Init /\\ [][Next]_vars +NoLostEdit == s # "Lost edit" /\\ ~(s = "Flash" /\\ flashed) +==== +`; + +test( + "a bug that only the fixed model has is numbered after the current bugs and drawn wrong", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const cfg = "SPECIFICATION Spec\nINVARIANT NoLostEdit\n"; + for (const [name, fixed] of [ + ["Edit", false], + ["EditFixed", true], + ]) { + writeFileSync(join(dir, `${name}.tla`), edit(name, fixed)); + writeFileSync(join(dir, `${name}.cfg`), cfg); + assert.match(tlc(dir, name).stdout, /Invariant NoLostEdit is violated/); + } + writeFileSync( + join(dir, "edit.map.mjs"), + 'export const readerActions = ["Tap"];\nexport const state = (v) => v.s;\nexport const step = (a) => ({ Tap: "you tap Save", Lose: "the save fails", Flash: "the screen flashes", Back: "you go back" })[a] ?? "";\n', + ); + const args = [ + "--dot", + "Edit.dot", + "--map", + "edit.map.mjs", + "--traces", + "Edit.out", + "--fixed-dot", + "EditFixed.dot", + "--fixed-traces", + "EditFixed.out", + ]; + const r = node( + "tlc-to-chart.mjs", + [ + ...args, + "--cover", + "--highlight", + "0", + "--story", + "story.md", + "--summary", + "summary.json", + "--out", + "chart.mmd", + ], + dir, + ); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /1 bug\(s\), 1 new with the fix/); + const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual( + summary.classes.map((c) => [c.n, c.end, c.fix]), + [ + [1, "Lost edit", "Saving"], + [2, "Flash", "new with the fix"], + ], + ); + assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 1 }); + assert.match(readFileSync(join(dir, "story.md"), "utf8"), /^## 2\. New with the fix: Flash$/m); + const two = node( + "tlc-to-chart.mjs", + [...args, "--highlight", "2", "--path-only", "--out", "bug2.mmd"], + dir, + ); + assert.equal(two.status, 0, two.stderr); + assert.match(readFileSync(join(dir, "bug2.mmd"), "utf8"), /: 2\. the screen flashes\n/); + }, +); + +function flatEdges(mmd) { + const names = new Map(); + const members = new Map(); + let box = null; + for (const line of mmd.split("\n")) { + const opens = /^ {2}state "([^"]+)" as (g\d+) \{$/.exec(line); + const state = /^ +state "([^"]+)" as (s\d+)$/.exec(line); + const said = (s) => s.replaceAll("
", " "); + if (opens) { + box = { id: opens[2], name: said(opens[1]) }; + members.set(box.id, []); + } else if (line === " }") box = null; + else if (state) { + names.set(state[2], box ? `${box.name}: ${said(state[1])}` : said(state[1])); + if (box) members.get(box.id).push(state[2]); + } + } + return [...mmd.matchAll(/^ {2}([gs]\d+) --> (s\d+) : (.*)$/gm)] + .flatMap(([, from, to, label]) => + (members.get(from) ?? [from]).map((f) => `${names.get(f)} --> ${names.get(to)} : ${label}`), + ) + .sort(); +} + +test( + "states of one context sit in one box, and an arrow from the box's border stands for the same step from each", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Window.tla"), + `---- MODULE Window ---- +VARIABLES where, reply +vars == <> +Init == where = "Starting" /\\ reply = "none" +Boot == where = "Starting" /\\ where' = "Chat open" /\\ UNCHANGED reply +Ask == where = "Chat open" /\\ reply = "none" /\\ reply' = "pending" /\\ UNCHANGED where +Answer == reply = "pending" /\\ reply' = "shown" /\\ UNCHANGED where +Close == where = "Chat open" /\\ where' = "Window closed" /\\ UNCHANGED reply +Open == where = "Window closed" /\\ where' = "Chat open" /\\ reply' = "none" +Next == Boot \\/ Ask \\/ Answer \\/ Close \\/ Open +Spec == Init /\\ [][Next]_vars +NoReplyWhileClosed == ~(where = "Window closed" /\\ reply = "shown") +==== +`, + ); + writeFileSync(join(dir, "Window.cfg"), "SPECIFICATION Spec\nINVARIANT NoReplyWhileClosed\n"); + writeFileSync( + join(dir, "window.map.mjs"), + `const R = { none: "no question", pending: "reply on its way", shown: "reply shown" }; +export const state = (v) => \`\${v.where}: \${v.where === "Starting" ? "loading" : R[v.reply]}\`; +export const step = (a) => ({ Boot: "the app starts", Ask: "you ask", Answer: "the reply arrives", Close: "you close the window", Open: "you open the window" })[a]; +`, + ); + assert.match(tlc(dir, "Window").stdout, /Invariant NoReplyWhileClosed is violated/); + const args = ["--dot", "Window.dot", "--map", "window.map.mjs", "--traces", "Window.out"]; + const draw = (extra, out) => { + const r = node("tlc-to-chart.mjs", [...args, ...extra, "--out", out], dir); + assert.equal(r.status, 0, r.stderr); + return readFileSync(join(dir, out), "utf8"); + }; + const boxed = draw([], "boxed.mmd"); + const flat = draw(["--flat"], "flat.mmd"); + + assert.match( + boxed, + /^ {2}state "Chat open" as g0 \{\n {4}state "no question" as s0\n {4}state "reply on its way" as s1\n {4}state "reply shown" as s2\n {2}\}\n {2}state "Window closed" as g1 \{\n {4}state "no question" as s3\n {4}state "reply on its way" as s4\n {4}state "reply shown" as s5\n {2}\}\n {2}state "Starting: loading" as s6\n/m, + ); + assert.deepEqual( + boxed.split("\n").filter((l) => /you open the window/.test(l)), + [" g1 --> s0 : you open the window"], + ); + assert.equal(flat.split("\n").filter((l) => /you open the window/.test(l)).length, 3); + assert.doesNotMatch(flat, /as g\d+ \{/); + assert.deepEqual(flatEdges(boxed), flatEdges(flat)); + assert.match(boxed, /^ {2}class s5 wrongPath$/m); + assert.match(boxed, /^ {2}s\d --> s5 : 4\. /m); + assert.match(boxed, /state "Breaks a rule" as legendWrong/); + }, +); + +const save = (name, code) => `---- MODULE ${name} ---- +VARIABLES screen, note${code ? ", retries" : ""} +vars == <> +Init == screen = "Editing" /\\ note = [text |-> "draft"${code ? `, rev |-> 0] /\\ retries = 0` : "]"} +Save == screen = "Editing" /\\ screen' = "Saving" /\\ UNCHANGED <> +Confirm == screen = "Saving" /\\ screen' = "Saved" /\\ note' = [text |-> "stored"${code ? `, rev |-> 1] /\\ UNCHANGED retries` : "]"} +Edit == screen = "Saved" /\\ screen' = "Editing" /\\ note' = [note EXCEPT !.text = "draft"]${code ? " /\\ retries' = 0" : ""} +${ + code + ? `Retry == screen = "Saving" /\\ retries = 0 /\\ retries' = 1 /\\ UNCHANGED <> +TimeOut == screen = "Saving" /\\ retries = 1 /\\ screen' = "Saved" /\\ UNCHANGED <> +Next == Save \\/ Retry \\/ Confirm \\/ TimeOut \\/ Edit` + : "Next == Save \\/ Confirm \\/ Edit" +} +Spec == Init /\\ [][Next]_vars +TypeOK == screen \\in {"Editing", "Saving", "Saved"} +\\* You never see "Saved" while the note is still a draft. +NoFalseSaved == ~(screen = "Saved" /\\ note.text = "draft") +==== +`; + +test( + "from-spec --against compares the code with the spec on the parts both have, and --expect-broken sets the exit code", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "SaveCode.tla"), save("SaveCode", true)); + writeFileSync(join(dir, "SaveAgreed.tla"), save("SaveAgreed", false)); + writeFileSync( + join(dir, "SaveCode.cfg"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NoFalseSaved\n", + ); + writeFileSync( + join(dir, "SaveCode.map.mjs"), + `export const readerActions = ["Save", "Edit"]; +export const state = (v) => \`\${v.screen}, \${v.note.text}\`; +export const step = (a) => ({ Save: "you press Save", Retry: "the app tries again", Confirm: "the server stores the note", TimeOut: "the save times out", Edit: "you edit the note" })[a] ?? ""; +export const cause = () => "the app stops after one retry and shows Saved anyway"; +`, + ); + const run = (...extra) => + node( + "statechart.mjs", + [ + "from-spec", + "SaveCode.tla", + "--against", + "SaveAgreed.tla", + "--label", + "the agreed chart", + ...extra, + ], + dir, + ); + + const unknown = run("--expect-broken", "NoLostNote"); + assert.equal(unknown.status, 2); + assert.equal( + unknown.stderr, + "--expect-broken NoLostNote: not a rule in SaveCode.cfg, whose rules are TypeOK, NoFalseSaved.\n", + ); + + const expected = run("--expect-broken", "NoFalseSaved"); + assert.equal(expected.status, 0, expected.stderr); + assert.equal( + expected.stdout, + `TLC on SaveCode.tla: + TypeOK holds. + NoFalseSaved is broken (expected). +TLC on SaveAgreed.tla: + TypeOK holds. + NoFalseSaved holds. + +Chart: 4 states and 4 transitions from 10 model states. +3 work, 1 breaks a rule, 0 only in the agreed chart. + +## 1. Saved, draft + +Rule broken: You never see "Saved" while the note is still a draft. +Why: the app stops after one retry and shows Saved anyway. +There are 2 ways to get here in the model. This is the shortest. + +0. Start: Editing, draft +1. You press Save. Now: Saving, draft +2. The save times out. Now: Saved, draft + +In the agreed chart, step 2 (the save times out) cannot happen, so you stay at: Saving, draft. + +Broken as expected: NoFalseSaved. + +Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart. +`, + ); + const out = join(dir, "SaveCode-statechart"); + const chart = readFileSync(join(out, "chart.mmd"), "utf8"); + assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(against\): the agreed chart\n/m); + assert.deepEqual( + [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]), + ["Works", "Breaks a rule"], + ); + assert.match( + chart, + /^ {2}s3 --> s3 : in the agreed chart, the savetimes out cannot happen$/m, + ); + const bug = readFileSync(join(out, "bug-1.mmd"), "utf8"); + assert.deepEqual([...bug.matchAll(/^ {2}state "([^"]+)" as s\d+$/gm)].map((m) => m[1]).sort(), [ + "Editing, draft", + "Saved, draft", + "Saving, draft", + ]); + const summary = JSON.parse(readFileSync(join(out, "summary.json"), "utf8")); + assert.deepEqual(summary.second.role, "against"); + assert.deepEqual(summary.second.lacks.sort(), ["note.rev", "retries"]); + assert.equal(summary.classes[0].against, "Saving, draft"); + assert.deepEqual(summary.codeLike, []); + + const unexpected = run(); + assert.equal(unexpected.status, 1, unexpected.stderr); + const holds = run("--expect-broken", "TypeOK"); + assert.equal(holds.status, 1, holds.stderr); + assert.match( + holds.stdout, + /\nBroken but not expected: NoFalseSaved\.\n\nExpected to break but holds: TypeOK\.\n/, + ); + const listed = run("--expect-broken", "NoFalseSaved,TypeOK"); + assert.equal(listed.status, 1, listed.stderr); + assert.match(listed.stdout, /\nExpected to break but holds: TypeOK\.\n/); + assert.doesNotMatch(listed.stdout, /Broken but not expected/); + + writeFileSync( + join(dir, "code.map.mjs"), + readFileSync(join(out, "../SaveCode.map.mjs"), "utf8").replace( + '"the app stops after one retry and shows Saved anyway"', + '"retryCount hit maxRetries"', + ), + ); + const coded = run("--map", "code.map.mjs", "--expect-broken", "NoFalseSaved"); + assert.equal(coded.status, 0, coded.stderr); + assert.match( + coded.stdout, + /These labels or causes read like code\. Write them in the reader's words in code\.map\.mjs:\n {2}retryCount hit maxRetries\n/, + ); + }, +); + +test( + "from-chart checks a chart whose regions guard each other, and draws it back unchanged", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "lamp.mmd"), lamp); + const r = node( + "statechart.mjs", + ["from-chart", "lamp.mmd", "--expect-broken", "LitWhileOff"], + dir, + ); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /\nThe spec draws lamp\.mmd back unchanged\.\n/); + }, +); + +const doorSpec = `---- MODULE Door ---- +VARIABLES where, reply +vars == <> +Init == where = "Open" /\\ reply = "none" +Ask == reply = "none" /\\ reply' = "pending" /\\ UNCHANGED where +Answer == where = "Open" /\\ reply = "pending" /\\ reply' = "shown" /\\ UNCHANGED where +Drop == where = "Closed" /\\ reply = "pending" /\\ reply' = "lost" /\\ UNCHANGED where +Close == where = "Open" /\\ where' = "Closed" /\\ UNCHANGED reply +Reopen == where = "Closed" /\\ where' = "Open" /\\ (reply' = reply \\/ (reply = "pending" /\\ reply' = "shown")) +Next == Ask \\/ Answer \\/ Drop \\/ Close \\/ Reopen +Spec == Init /\\ [][Next]_vars +NoLostReply == reply # "lost" +NoShownWhileClosed == ~(where = "Closed" /\\ reply = "shown") +==== +`; + +const doorMap = `const W = { Open: "Window open", Closed: "Window closed" }; +const R = { none: "no question", pending: "reply on its way", shown: "reply shown", lost: "reply lost" }; +export const regionNames = ["Where you are", "Your reply"]; +export const state = (v) => \`\${W[v.where]}: \${R[v.reply]}\`; +export const step = (a) => ({ Ask: "you ask", Answer: "the reply arrives", Drop: "the app drops the reply", Close: "you close the window", Reopen: "you open the window" })[a]; +`; + +function runDoor(map) { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "Door.tla"), doorSpec); + writeFileSync(join(dir, "Door.map.mjs"), map); + const out = join(dir, "Door-statechart"); + const read = (f) => readFileSync(join(out, f), "utf8").replaceAll("
", " "); + return (rule) => { + writeFileSync(join(dir, "Door.cfg"), `SPECIFICATION Spec\nINVARIANT ${rule}\n`); + const r = node("statechart.mjs", ["from-spec", "Door.tla", "--expect-broken", rule], dir); + assert.equal(r.status, 0, r.stdout + r.stderr); + const every = join(out, "tlc", "regions-all.mmd"); + return { + stdout: r.stdout, + chart: read("chart.mmd"), + every: existsSync(every) ? read("tlc/regions-all.mmd") : null, + bug: read("bug-1.mmd"), + out, + }; + }; +} + +test( + "from-spec draws parallel regions only when they combine back into TLC's graph, and boxes otherwise", + needsTlc, + () => { + const run = runDoor(doorMap); + const proved = run("NoLostReply"); + assert.match( + proved.stdout, + /\nChart: 6 states and 5 transitions from 8 model states\.\nIt has 2 parallel regions, of 2 and 4 states\. Proof: their 7 arrows together make exactly the 8 states and 13 labeled steps of Door-statechart\/tlc\/regions-proof\.mmd, and the 5 drawn arrows make only steps among them\. Door-statechart\/tlc\/regions-all\.mmd has all 7 arrows\.\n5 work, 1 breaks a rule\.\n/, + ); + const lines = proved.chart.split("\n"); + assert.ok(lines.includes(' state "Where you are" as region0 {')); + assert.ok(lines.includes(" --")); + const guarded = lines.findIndex((l) => /: the reply arrives \[when Window open\]$/.test(l)); + assert.match(lines[guarded + 1], /^ {6}%% when r0s\d$/); + assert.doesNotMatch(proved.chart, /^ {6}r1s\d --> r1s\d : you open the window/m); + assert.equal( + proved.every + .split("\n") + .filter((l) => /^ {6}(r1s\d) --> \1 : you open the window \[when Window closed\]$/.test(l)) + .length, + 1, + ); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + proved.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + + const boxed = run("NoShownWhileClosed"); + assert.equal(boxed.every, null); + assert.match( + boxed.stdout, + /\nIt has no parallel regions, because "Window closed: reply shown" is "Breaks a rule", and its two region states would show "Works"\.\n/, + ); + assert.doesNotMatch(boxed.chart, /^ +--$/m); + assert.match(boxed.chart, /^ {2}state "Window closed" as g\d \{$/m); + }, +); + +test( + "the mapping's guard words show on an arrow only when every model step behind it has them", + needsTlc, + () => { + const run = runDoor( + `${doorMap}export const guard = (a, before) => + ({ Answer: "window open", Drop: "window closed", Ask: before.where === "Open" ? "window open" : "" })[a] ?? ""; +`, + ); + const proved = run("NoLostReply"); + const lines = proved.chart.split("\n"); + for (const words of [ + /: the reply arrives \[window open\]$/, + /: the app drops the reply \[window closed\]$/, + ]) + assert.match(lines[lines.findIndex((l) => words.test(l)) + 1], /^ {6}%% when r0s\d$/); + assert.ok(lines.some((l) => /^ {6}r1s\d --> r1s\d : you ask$/.test(l))); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + proved.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + assert.match(proved.bug, / : \d\. the app drops the reply \[window closed\]$/m); + + const boxed = run("NoShownWhileClosed"); + assert.match(boxed.chart, / : you ask \[window open\]$/m); + assert.match(boxed.chart, / : you ask$/m); + }, +); + +test( + "the mapping's timer() writes after N s on timer steps, and refuses a value that is not seconds", + needsTlc, + () => { + const timed = runDoor( + `${doorMap}export const timer = (a) => (a === "Drop" ? 30 : undefined);\n`, + )("NoLostReply"); + assert.match(timed.chart, / : after 30 s, the app drops the reply \[when Window closed\]$/m); + assert.match(timed.bug, / : \d\. after 30 s, the app drops the reply$/m); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + timed.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "Door.tla"), doorSpec); + writeFileSync(join(dir, "Door.cfg"), "SPECIFICATION Spec\nINVARIANT NoLostReply\n"); + writeFileSync(join(dir, "Door.map.mjs"), `${doorMap}export const timer = () => "soon";\n`); + const r = node("statechart.mjs", ["from-spec", "Door.tla"], dir); + assert.notEqual(r.status, 0); + assert.match( + r.stderr, + /timer\(\) must return a number of seconds above 0, or nothing; got "soon" for \w+/, + ); + }, +); + +const groupMap = `${doorMap}export const group = (v) => + ({ none: "Nothing to show", pending: "Waiting", shown: "Reply shown", lost: "Reply shown" })[v.reply]; +export const groupColors = { "Reply shown": "#d1f2eb", Waiting: null, "Never drawn": "#ffffff" }; +`; + +test( + "group() fills each state with its group's color, and the legend lists only the groups the chart draws", + needsTlc, + () => { + const run = runDoor(groupMap)("NoLostReply"); + const lines = run.chart.split("\n"); + assert.deepEqual(lines.slice(0, lines.indexOf(' state "At the same time" as regions {')), [ + "---", + "config:", + " markdownAutoWrap: false", + " state:", + " useMaxWidth: false", + "---", + "stateDiagram-v2", + " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631", + " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c", + " classDef group0 fill:#d1f2eb,stroke:#697976,color:#1b2631", + " classDef group0Wrong fill:#d1f2eb,stroke:#c0392b,stroke-width:4px,color:#7b241c", + " classDef group1 fill:#d6eaf8,stroke:#6b757c,color:#1b2631", + " classDef group2 fill:#fdebd0,stroke:#7f7668,color:#1b2631", + ' state "Legend" as legend {', + ' state "Reply shown" as legendGroup0', + ' state "Waiting" as legendGroup1', + ' state "Nothing to show" as legendGroup2', + ' state "Breaks a rule" as legendWrong', + " }", + " class legendGroup0 group0", + " class legendGroup1 group1", + " class legendGroup2 group2", + " class legendWrong plainWrong", + ]); + assert.ok(lines.includes(' state "reply lost" as r1s1')); + assert.deepEqual( + lines.filter((l) => l.startsWith(" class r")), + [ + " class r0s0 plain", + " class r0s1 plain", + " class r1s0 group2", + " class r1s1 group0Wrong", + " class r1s2 group1", + " class r1s3 group0", + ], + ); + assert.match(run.stdout, /\n5 work, 1 breaks a rule\.\n/); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + run.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + + const bug = run.bug.split("\n"); + assert.ok( + bug.includes( + " classDef group1Path fill:#d6eaf8,stroke:#1f3a93,stroke-width:4px,color:#1b2631", + ), + ); + assert.deepEqual( + bug.filter((l) => /^ {2}class s\d/.test(l)), + [ + " class s0 group0Wrong", + " class s1 group1Path", + " class s2 group2Path", + " class s3 group1Path", + ], + ); + + const summary = JSON.parse(readFileSync(join(run.out, "summary.json"), "utf8")); + assert.deepEqual(summary.chartGroups, [ + { name: "Reply shown", color: "#d1f2eb", states: 2 }, + { name: "Waiting", color: "#d6eaf8", states: 1 }, + { name: "Nothing to show", color: "#fdebd0", states: 1 }, + ]); + assert.deepEqual(summary.mixedGroups, []); + }, +); + +test( + "the mapping cannot hide a broken rule, and refuses a status name, a dark or bad color, or a group that is not a name", + needsTlc, + () => { + const run = runDoor(groupMap)("NoLostReply"); + const tlcDir = join(run.out, "tlc"); + const chart = (map, ...extra) => { + writeFileSync(join(tlcDir, "probe.map.mjs"), map); + return node( + "tlc-to-chart.mjs", + [ + "--dot", + "Door.dot", + "--map", + "probe.map.mjs", + "--traces", + "Door-NoLostReply.out", + "--highlight", + "0", + ...extra, + ], + tlcDir, + ); + }; + + const hidden = chart( + `${doorMap}export const group = () => "All good";\nexport const groupColors = { "All good": "#e9f7ef" };\n`, + "--regions", + ); + assert.equal(hidden.status, 0, hidden.stderr); + const lines = hidden.stdout.split("\n"); + assert.ok( + lines.includes( + " classDef group0Wrong fill:#e9f7ef,stroke:#c0392b,stroke-width:4px,color:#7b241c", + ), + ); + const legend = lines.indexOf(' state "Legend" as legend {'); + assert.deepEqual(lines.slice(legend, legend + 6), [ + ' state "Legend" as legend {', + ' state "All good" as legendGroup0', + ' state "Breaks a rule" as legendWrong', + " }", + " class legendGroup0 group0", + " class legendWrong plainWrong", + ]); + assert.ok(lines.includes(" class r1s1 group0Wrong")); + assert.equal(lines.filter((l) => / group0Wrong$/.test(l)).length, 1); + + const coarse = chart( + `const W = { Open: "Window open", Closed: "Window closed" }; +export const state = (v) => W[v.where]; +export const group = (v) => (v.reply === "shown" ? "Reply shown" : "Waiting"); +`, + "--summary", + "coarse.json", + ); + assert.equal(coarse.status, 0, coarse.stderr); + assert.deepEqual( + coarse.stdout.split("\n").filter((l) => /^ {2}(class|classDef) /.test(l)), + [ + " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631", + " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c", + " class legendWrong plainWrong", + " class s0 plainWrong", + " class s1 plain", + ], + ); + const summary = JSON.parse(readFileSync(join(tlcDir, "coarse.json"), "utf8")); + assert.deepEqual(summary.chartGroups, []); + assert.deepEqual(summary.mixedGroups, ["Window closed", "Window open"]); + + for (const [map, error] of [ + [ + `export const group = (v) => (v.reply === "lost" ? "Breaks a rule" : "Fine");`, + /group "Breaks a rule" has the name of a status; TLC decides the status, so name the group for what the reader sees/, + ], + [`export const group = () => "fixed";`, /group "fixed" has the name of a status/], + [ + `export const group = () => "All good";\nexport const groupColors = { "All good": "#123" };`, + /groupColors\["All good"\] "#123" is too dark for the status borders to show on it/, + ], + [ + `export const group = () => "All good";\nexport const groupColors = { "All good": "red" };`, + /groupColors\["All good"\] "red" is not "#rgb" or "#rrggbb"/, + ], + [ + `export const group = () => "All good";\nexport const groupColors = ["#ffffff"];`, + /groupColors must be an object from a group name to a color such as #d6eaf8/, + ], + [`export const group = () => 1;`, /group\(\) must return a group name or nothing; got 1/], + ]) { + const r = chart(`${doorMap}${map}\n`, "--regions"); + assert.equal(r.status, 1, map); + assert.equal(r.stdout, "", map); + assert.match(r.stderr, error); + } + }, +); + +const order = (name, note, charge) => `---- MODULE ${name} ---- +EXTENDS Naturals +VARIABLES screen, charges, note +vars == <> +Init == screen = "Cart" /\\ charges = 0 /\\ note = 0 +Pay == screen = "Cart" /\\ screen' = "Paying" /\\ note' = ${note} /\\ UNCHANGED charges +Charge == screen = "Paying" /\\ charges < 2 /\\ ${charge} +Next == Pay \\/ Charge +Spec == Init /\\ [][Next]_vars +NoDoubleCharge == charges <= 1 +==== +`; +const chargeAgain = "charges' = charges + 1 /\\ UNCHANGED <>"; +const chargeOnce = `IF charges = 0 THEN charges' = 1 /\\ UNCHANGED <> ELSE screen' = "Paid" /\\ UNCHANGED <>`; +const chargeFirstOnly = "charges = 0 /\\ charges' = 1 /\\ UNCHANGED <>"; + +test( + "the fix changes a bug only at the first step that the reader sees differently", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "Order.tla"), order("Order", 0, chargeAgain)); + writeFileSync(join(dir, "Order.cfg"), "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"); + const withFix = (charge, map = payMap) => { + writeFileSync(join(dir, "Order.map.mjs"), map); + writeFileSync(join(dir, "OrderFixed.tla"), order("OrderFixed", 1, charge)); + const r = node( + "statechart.mjs", + ["from-spec", "Order.tla", "--fixed", "OrderFixed.tla"], + dir, + ); + assert.equal(r.status, 1, r.stderr); + const chart = readFileSync(join(dir, "Order-statechart", "chart.mmd"), "utf8"); + return { + story: readFileSync(join(dir, "Order-statechart", "stories.md"), "utf8"), + fixEdges: parseMermaid(chart) + .regions.Main.transitions.filter((t) => t.label.includes("with the fix")) + .map((t) => `${t.from} --> ${t.to} : ${t.label}`), + }; + }; + const hidden = withFix(chargeAgain); + assert.deepEqual(hidden.fixEdges, []); + assert.match( + hidden.story, + /^With the fix you see the same screens and steps, so the fix does not change this path\.$/m, + ); + const once = withFix(chargeOnce); + assert.deepEqual(once.fixEdges, [ + "Paying, charged --> Paid, charged : with the fix, the bank charges you", + ]); + assert.match( + once.story, + /^With the fix, step 3 \(the bank charges you\) takes you to: Paid, charged\.$/m, + ); + const unlabeledMap = payMap.replace( + /^export const state = .*$/m, + "export const state = (v) => v.screen;", + ); + const unseen = withFix(chargeOnce, unlabeledMap); + assert.deepEqual(unseen.fixEdges, ["Paying --> Paid : with the fix, the bank charges you"]); + assert.match( + unseen.story, + /^With the fix, after step 1, "the bank charges you" happens and takes you to: Paid\.$/m, + ); + const blocked = withFix(chargeFirstOnly, unlabeledMap); + assert.deepEqual(blocked.fixEdges, []); + assert.match( + blocked.story, + /yet the rule holds, so the state labels do not show what the fix changes/, + ); + }, +); + +const notify = (name, mutedArrive) => `---- MODULE ${name} ---- +EXTENDS Naturals +VARIABLES screen, muted, banner, badge, missed, msgs, syncs +vars == <> +Init == screen = "Inbox" /\\ muted = FALSE /\\ banner = FALSE /\\ badge = FALSE + /\\ missed = FALSE /\\ msgs = 0 /\\ syncs = 0 +OpenThread == screen = "Inbox" /\\ screen' = "Thread" /\\ UNCHANGED <> +OpenSettings == screen \\in {"Inbox", "Thread"} /\\ screen' = "Settings" /\\ UNCHANGED <> +TapBack == screen \\in {"Thread", "Settings"} /\\ screen' = "Inbox" /\\ UNCHANGED <> +Mute == screen = "Settings" /\\ ~muted /\\ muted' = TRUE /\\ UNCHANGED <> +Refresh == syncs < 2 /\\ syncs' = syncs + 1 /\\ UNCHANGED <> +Arrive == msgs < syncs /\\ msgs' = msgs + 1 + /\\ (IF muted THEN ${mutedArrive} ELSE banner' = TRUE /\\ UNCHANGED <>) + /\\ UNCHANGED <> +Next == OpenThread \\/ OpenSettings \\/ TapBack \\/ Mute \\/ Refresh \\/ Arrive +Spec == Init /\\ [][Next]_vars +NeverMissed == ~missed +NoBannerOnThread == ~(banner /\\ screen = "Thread") +==== +`; + +test( + "a step the reader does not see breaks the rule, and the fix shows it on another screen", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Notify.tla"), + notify("Notify", "missed' = TRUE /\\ UNCHANGED <>"), + ); + writeFileSync( + join(dir, "NotifyBadge.tla"), + notify("NotifyBadge", "badge' = TRUE /\\ UNCHANGED <>"), + ); + writeFileSync( + join(dir, "Notify.cfg"), + "SPECIFICATION Spec\nINVARIANT NeverMissed\nINVARIANT NoBannerOnThread\n", + ); + writeFileSync( + join(dir, "Notify.map.mjs"), + `export const readerActions = ["OpenThread", "OpenSettings", "TapBack", "Mute", "Refresh"]; +export const state = (v) => [v.screen, v.muted && "muted", v.banner && "banner", v.badge && "badge"].filter(Boolean).join(", "); +export const step = (a) => ({ OpenThread: "you open the thread", OpenSettings: "you open settings", TapBack: "you tap Back", Mute: "you mute alerts", Refresh: "you pull to refresh", Arrive: "a message arrives" })[a]; +export const cause = () => null; +`, + ); + const r = node( + "statechart.mjs", + ["from-spec", "Notify.tla", "--fixed", "NotifyBadge.tla", "--label", "the badge fix"], + dir, + ); + assert.equal(r.status, 1, r.stderr); + const out = (f) => readFileSync(join(dir, "Notify-statechart", f), "utf8"); + const fixEdges = (f) => + parseMermaid(out(f)) + .regions.Main.transitions.filter((t) => t.label.includes("with the badge fix")) + .map((t) => `${t.from} --> ${t.to} : ${t.label}`); + const { classes } = JSON.parse(out("summary.json")); + assert.deepEqual( + classes.map((c) => [c.invariant, c.end, c.fix]), + [ + ["NoBannerOnThread", "Thread, banner", "unchanged"], + ["NeverMissed", "Settings, muted", "Settings, muted, badge"], + ["NeverMissed", "Inbox, muted", "Inbox, muted, badge"], + ["NeverMissed", "Settings, muted, banner", "Settings, muted, banner, badge"], + ["NeverMissed", "Thread, muted", "Thread, muted, badge"], + ["NoBannerOnThread", "Thread, muted, banner", "unchanged"], + ["NeverMissed", "Inbox, muted, banner", "Inbox, muted, banner, badge"], + ["NeverMissed", "Thread, muted, banner", "Thread, muted, banner, badge"], + ["NoBannerOnThread", "Thread, muted, banner, badge", "new with the badge fix"], + ], + ); + const missed = classes.filter((c) => c.invariant === "NeverMissed"); + + const story = out("stories.md"); + assert.doesNotMatch(story, /do not show what/); + assert.match( + story, + /^With the badge fix, after step 3, "a message arrives" happens and takes you to: Settings, muted, badge\.$/m, + ); + const settings = classes.find((c) => c.end === "Settings, muted"); + assert.deepEqual(fixEdges(`bug-${settings.n}.mmd`), [ + "Settings, muted --> Settings, muted, badge : with the badge fix, a message arrives", + ]); + const drawn = fixEdges("chart.mmd"); + assert.equal(new Set(drawn).size, drawn.length); + for (const c of missed) + assert.ok( + drawn.includes(`${c.end} --> ${c.end}, badge : with the badge fix, a message arrives`), + `bug ${c.n}`, + ); + }, +); + +test( + "each looping step on a bug path keeps its own label, a null cause groups bugs by end state, and the mapping names the key's colors", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Browse.tla"), + `---- MODULE Browse ---- +EXTENDS Naturals +VARIABLES screen, looks, scrolled +vars == <> +Init == screen = "Cart" /\\ looks = 0 /\\ scrolled = FALSE +Look == screen = "Cart" /\\ looks < 3 /\\ looks' = looks + 1 /\\ UNCHANGED <> +Scroll == screen = "Cart" /\\ looks = 1 /\\ ~scrolled /\\ scrolled' = TRUE /\\ UNCHANGED <> +Pay == screen = "Cart" /\\ looks = 3 /\\ scrolled /\\ screen' = "Paying" /\\ UNCHANGED <> +Fail == screen = "Paying" /\\ screen' = "Failed" /\\ UNCHANGED <> +Hang == screen = "Paying" /\\ screen' = "Stuck" /\\ UNCHANGED <> +Next == Look \\/ Scroll \\/ Pay \\/ Fail \\/ Hang +Spec == Init /\\ [][Next]_vars +NeverLost == screen \\notin {"Failed", "Stuck"} +==== +`, + ); + writeFileSync(join(dir, "Browse.cfg"), "SPECIFICATION Spec\nINVARIANT NeverLost\n"); + writeFileSync( + join(dir, "Browse.map.mjs"), + `export const readerActions = ["Look", "Scroll", "Pay"]; +export const state = (v) => v.screen; +export const step = (a) => ({ Look: "you look", Scroll: "you scroll", Pay: "you pay", Fail: "the payment fails", Hang: "the payment hangs" })[a]; +export const cause = () => null; +export const legend = { works: "Works on main", wrong: "Wrong on main" }; +`, + ); + const r = node("statechart.mjs", ["from-spec", "Browse.tla"], dir); + assert.equal(r.status, 1, r.stderr); + const out = (f) => readFileSync(join(dir, "Browse-statechart", f), "utf8"); + assert.deepEqual( + JSON.parse(out("summary.json")).classes.map((c) => [c.end, c.cause]), + [ + ["Failed", null], + ["Stuck", null], + ], + ); + assert.doesNotMatch(out("stories.md"), /^Why:|null/m); + const loops = (chart) => [...chart.matchAll(/^ {2}(s\d+) --> \1 : (.*)$/gm)].map((m) => m[2]); + assert.deepEqual(loops(out("bug-1.mmd")), ["1. you look
2. you scroll
3, 4. you look"]); + assert.deepEqual(loops(out("chart.mmd")), ["you look or you scroll"]); + const chart = out("chart.mmd"); + assert.ok(chart.includes('state "Works on main" as legendWorks')); + assert.ok(chart.includes('state "Wrong on main" as legendWrong')); + assert.doesNotMatch(chart, /legendFixed/); + }, +); + +test("a rule that stays broken reports only the step that broke it", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Buzz.tla"), + `---- MODULE Buzz ---- +EXTENDS Naturals +VARIABLES x, y, buzzed +vars == <> +Init == x = 0 /\\ y = 0 /\\ buzzed = FALSE +IncX == x < 6 /\\ x' = x + 1 /\\ UNCHANGED <> +IncY == y < 6 /\\ y' = y + 1 /\\ UNCHANGED <> +Ring == x = 2 /\\ ~buzzed /\\ buzzed' = TRUE /\\ UNCHANGED <> +Next == IncX \\/ IncY \\/ Ring +Spec == Init /\\ [][Next]_vars +NeverBuzzed == ~buzzed +==== +`, + ); + writeFileSync(join(dir, "Buzz.cfg"), "SPECIFICATION Spec\nINVARIANT NeverBuzzed\n"); + writeFileSync( + join(dir, "Buzz.map.mjs"), + 'export const state = (v) => (v.buzzed ? "Buzzed" : "Quiet");\nexport const step = () => "time passes";\n', + ); + const r = node("statechart.mjs", ["from-spec", "Buzz.tla"], dir); + assert.equal(r.status, 1, r.stderr); + const traces = parseTraces( + readFileSync(join(dir, "Buzz-statechart", "tlc", "Buzz-NeverBuzzed.out"), "utf8"), + ); + assert.equal(traces.length, 7); + for (const t of traces) + assert.equal( + t.steps.findIndex((s) => s.vars.buzzed), + t.steps.length - 1, + ); +}); + +test("from-spec warns when steps carry the name of the whole next-state action", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const count = (next) => `---- MODULE Count ---- +EXTENDS Naturals +VARIABLES x +Init == x = 0 +Inc == x < 3 /\\ x' = x + 1 +${next} +Spec == \\* not [][Inc]_x + Init /\\ [][Next]_x +==== +`; + writeFileSync(join(dir, "Count.cfg"), "SPECIFICATION Spec\n"); + writeFileSync(join(dir, "Count.map.mjs"), "export const state = (v) => String(v.x);\n"); + writeFileSync(join(dir, "Count.tla"), count("Next == Inc \\/ (x = 3 /\\ x' = 0)")); + const unnamed = node("statechart.mjs", ["from-spec", "Count.tla"], dir); + assert.equal(unnamed.status, 0, unnamed.stderr); + assert.match( + unnamed.stderr, + /^Count\.tla: 1 step has the name Next, the whole next-state action/, + ); + writeFileSync(join(dir, "Count.tla"), count("Next == Inc")); + const named = node("statechart.mjs", ["from-spec", "Count.tla"], dir); + assert.equal(named.status, 0, named.stderr); + assert.equal(named.stderr, ""); +}); + +const writeLamp = (dir) => { + writeFileSync( + join(dir, "Lamp.tla"), + "---- MODULE Lamp ----\nVARIABLE on\nInit == on = FALSE\nOn == on' = TRUE\nOff == on' = FALSE\nSpec == Init /\\ [][On \\/ Off]_on\n====\n", + ); + writeFileSync(join(dir, "Lamp.cfg"), "SPECIFICATION Spec\n"); + writeFileSync( + join(dir, "Lamp.map.mjs"), + 'export const state = (v) => (v.on ? "On" : "Off");\nexport const step = () => "you flip the switch";\n', + ); + return join(dir, "Lamp-statechart"); +}; + +test("a rerun without --png removes the chart.png of an earlier run", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeLamp(dir); + mkdirSync(out); + writeFileSync(join(out, "chart.png"), ""); + const r = node("statechart.mjs", ["from-spec", "Lamp.tla"], dir); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual( + readdirSync(out).filter((f) => f.endsWith(".png")), + [], + ); +}); + +const writeSlowPay = (dir, ticks, paid = "Paid") => { + writeFileSync( + join(dir, "Pay.tla"), + `---- MODULE Pay ---- +EXTENDS Naturals +VARIABLES screen, charges, ticks +vars == <> +Init == screen = "Cart" /\\ charges = 0 /\\ ticks = 0 +TapPay == screen = "Cart" /\\ screen' = "Paying" /\\ UNCHANGED <> +Charge == screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED <> +Done == screen = "Paying" /\\ charges > 0 /\\ screen' = "${paid}" /\\ UNCHANGED <> +Tick == ticks < ${ticks} /\\ ticks' = ticks + 1 /\\ UNCHANGED <> +Spec == Init /\\ [][TapPay \\/ Charge \\/ Done \\/ Tick]_vars +ChargedAtMostTwice == charges <= 2 +==== +`, + ); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT ChargedAtMostTwice\n"); + writeFileSync( + join(dir, "Pay.map.mjs"), + 'export const state = (v) => v.screen;\nexport const step = (a) => ({ TapPay: "you tap Pay", Charge: "the charge goes through", Done: "the receipt shows" })[a] ?? "";\n', + ); + return join(dir, "Pay-statechart"); +}; +const startRun = (dir, { wrap = [], env = {}, args = ["from-spec", "Pay.tla"] } = {}) => { + const [command, ...rest] = [...wrap, process.execPath, join(scripts, "statechart.mjs"), ...args]; + const child = spawn(command, rest, { + cwd: dir, + env: { ...process.env, ...env }, + stdio: ["ignore", "ignore", "pipe"], + detached: true, + }); + let stderr = ""; + child.stderr.on("data", (d) => { + stderr += d; + }); + return { + child, + done: new Promise((done) => child.on("close", (status) => done({ status, stderr }))), + }; +}; +const until = async (ready) => { + while (!ready()) await sleep(20); +}; +const ownTmp = (dir, name) => { + const tmp = join(dir, name); + mkdirSync(tmp); + return tmp; +}; +const tlcStarted = (tmp) => readdirSync(tmp).some((f) => f.startsWith("tlc-")); +const staged = (dir) => readdirSync(dir).filter((f) => f.startsWith(".")); + +test( + "a run whose Node is killed mid-TLC leaves the output folder as it was, and a rerun charts its own spec", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 60000); + const tmp = ownTmp(dir, "tmp"); + const first = startRun(dir, { env: { TMPDIR: tmp } }); + await until(() => tlcStarted(tmp)); + await sleep(300); + first.child.kill("SIGTERM"); + await first.done; + assert.doesNotThrow(() => process.kill(-first.child.pid, 0), "TLC outlives its Node"); + assert.equal(existsSync(out), false); + + writeSlowPay(dir, 200, "Receipt"); + const second = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + process.kill(-first.child.pid, "SIGKILL"); + assert.equal(second.status, 0, second.stderr); + for (const file of ["chart.mmd", join("tlc", "Pay.dot")]) { + const text = readFileSync(join(out, file), "utf8"); + assert.match(text, /Receipt/); + assert.doesNotMatch(text, /Paid/); + } + assert.deepEqual(staged(dir), []); + }, +); + +const changedMidRun = async ({ wrap = [], env = {} }) => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 20000); + const tmp = ownTmp(dir, "tmp-a"); + const first = startRun(dir, { wrap, env: { ...env, TMPDIR: tmp } }); + await until(() => tlcStarted(tmp)); + await sleep(500); + writeSlowPay(dir, 20000, "Receipt"); + const second = startRun(dir, { wrap, env: { ...env, TMPDIR: ownTmp(dir, "tmp-b") } }); + const [paid, receipt] = await Promise.all([first.done, second.done]); + assert.equal(receipt.status, 0, receipt.stderr); + assert.equal(paid.status, 2, paid.stderr); + assert.equal( + paid.stderr, + "Pay.tla changed while this run read it, so the run left Pay-statechart as it was. Run the command again.\n", + ); + const chart = readFileSync(join(out, "chart.mmd"), "utf8"); + assert.match(chart, /Receipt/); + assert.doesNotMatch(chart, /Paid/); + assert.deepEqual(staged(dir), []); +}; + +test( + "a run whose spec changes while it reads it leaves the output folder, and the run that read the new spec charts it", + needsTlc, + () => changedMidRun({}), +); + +const unshare = ["unshare", "--user", "--map-root-user", "--pid", "--fork", "--mount-proc"]; +const hidePidNamespace = `#define _GNU_SOURCE +#include +#include +#include +#include +ssize_t readlink(const char *path, char *buf, size_t size) { + static ssize_t (*real)(const char *, char *, size_t); + if (!real) real = dlsym(RTLD_NEXT, "readlink"); + if (path && strcmp(path, "/proc/self/ns/pid") == 0) { errno = EIO; return -1; } + return real(path, buf, size); +} +`; +const hidingPreload = (() => { + if (spawnSync(unshare[0], [...unshare.slice(1), "true"]).status !== 0) return null; + const dir = mkdtempSync(join(tmpdir(), "statechart-preload-")); + writeFileSync(join(dir, "hide.c"), hidePidNamespace); + const so = join(dir, "hide.so"); + const cc = spawnSync("cc", ["-shared", "-fPIC", "-o", so, join(dir, "hide.c"), "-ldl"]); + return cc.status === 0 ? so : null; +})(); +test( + "runs in PID namespaces that hide which namespace they are in chart only their own spec", + { + skip: + needsTlc.skip || + (!hidingPreload && "cannot make a PID namespace, or cannot compile a readlink preload"), + }, + () => changedMidRun({ wrap: unshare, env: { LD_PRELOAD: hidingPreload } }), +); + +test( + "a file that is put in the output folder during a run stays, and the run exits 2", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 20000); + const tmp = ownTmp(dir, "tmp"); + const run = startRun(dir, { env: { TMPDIR: tmp } }); + await until(() => tlcStarted(tmp)); + mkdirSync(out); + writeFileSync(join(out, "notes.md"), "mine\n"); + const r = await run.done; + assert.equal(r.status, 2); + assert.equal( + r.stderr, + "Pay-statechart has a file that this command did not write: notes.md. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + ); + assert.deepEqual(readdirSync(out), ["notes.md"]); + assert.deepEqual(staged(dir), []); + }, +); + +const failRenames = `#define _GNU_SOURCE +#include +#include +#include +#include +static size_t hits; +int rename(const char *from, const char *to) { + static int (*real)(const char *, const char *); + if (!real) real = dlsym(RTLD_NEXT, "rename"); + const char *base = strrchr(to, '/'), *fails = getenv("RENAME_FAILS"); + base = base ? base + 1 : to; + if (fails && strcmp(base, "shared") == 0 && hits < strlen(fails)) { + errno = fails[hits++] == 'N' ? ENOTEMPTY : EIO; + return -1; + } + return real(from, to); +} +`; +const failingRenames = (() => { + const dir = mkdtempSync(join(tmpdir(), "statechart-preload-")); + writeFileSync(join(dir, "rename.c"), failRenames); + const so = join(dir, "rename.so"); + const cc = spawnSync("cc", ["-shared", "-fPIC", "-o", so, join(dir, "rename.c"), "-ldl"]); + return cc.status === 0 ? so : null; +})(); +test( + "a run whose rename fails after it moves the output folder aside puts the folder back, or says where it is", + { skip: needsTlc.skip || (!failingRenames && "cannot compile a rename preload") }, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeLamp(dir); + const args = ["from-spec", "Lamp.tla", "--out", "shared"]; + const shared = join(dir, "shared"); + const first = node("statechart.mjs", args, dir); + assert.equal(first.status, 0, first.stderr); + const chart = readFileSync(join(shared, "chart.mmd"), "utf8"); + const run = (fails) => + node("statechart.mjs", args, dir, { LD_PRELOAD: failingRenames, RENAME_FAILS: fails }); + + const back = run("NI"); + assert.equal(back.status, 2, back.stderr); + assert.match( + back.stderr, + /^cannot replace shared: EIO: .*\. The run left shared as it was\.\n$/, + ); + assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), chart); + assert.deepEqual(staged(dir), []); + + const stuck = run("NII"); + assert.equal(stuck.status, 2, stuck.stderr); + const named = stuck.stderr.match( + /^cannot replace shared: EIO: .*\. This run moved the previous shared to (\.shared-\w+-old) and could not put it back\.\n$/, + ); + assert.ok(named, stuck.stderr); + const aside = named[1]; + assert.equal(existsSync(shared), false); + assert.equal(readFileSync(join(dir, aside, "chart.mmd"), "utf8"), chart); + assert.deepEqual(staged(dir), [aside]); + }, +); + +test( + "each TLC run writes the standard modules in its own temp folder, so overlapping runs cannot rewrite them under each other", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeSlowPay(dir, 0); + const tmp = ownTmp(dir, "tmp"); + const log = join(dir, "java.log"); + const java = join(dir, "java"); + writeFileSync( + java, + `#!/bin/sh +for a; do case "$a" in -Djava.io.tmpdir=*) t="\${a#-Djava.io.tmpdir=}";; esac; done +"$REAL_JAVA" "$@" > "$LOG.$$"; s=$? +cat "$LOG.$$" +echo "$t: $(grep 'Naturals.tla' "$LOG.$$")" >> "$LOG" +rm "$LOG.$$" +exit $s +`, + { mode: 0o755 }, + ); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, { + JAVA: java, + TLA2TOOLS: tools.jar, + REAL_JAVA: tools.java, + LOG: log, + TMPDIR: tmp, + }); + assert.equal(r.status, 0, r.stderr); + const runs = readFileSync(log, "utf8").trim().split("\n"); + assert.ok(runs.length > 0); + const folders = new Set(); + for (const line of runs) { + const [folder, parsed] = line.split(": "); + assert.ok(folder.startsWith(join(tmp, "tlc-")), line); + assert.equal(parsed, `Parsing file ${join(folder, "Naturals.tla")}`); + folders.add(folder); + } + assert.equal(folders.size, runs.length); + }, +); + +test( + "a run that TLC stops on an error keeps TLC's output and writes no output folder", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + "---- MODULE Pay ----\nVARIABLE x\nASSUME FALSE\nInit == x = 0\nSpec == Init /\\ [][x' = x]_x\n====\n", + ); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\n"); + writeFileSync(join(dir, "Pay.map.mjs"), 'export const state = () => "Cart";\n'); + const tmp = ownTmp(dir, "tmp"); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, { TMPDIR: tmp }); + assert.equal(r.status, 2); + const [, file] = r.stderr.match( + /^(\S+\/tlc\/Pay\.out): TLC stopped on an error that is not a broken rule: Assumption .* is false\.\n$/, + ); + assert.ok(file.startsWith(tmp), file); + assert.match(readFileSync(file, "utf8"), /Assumption .* is false/); + assert.equal(existsSync(join(dir, "Pay-statechart")), false); + }, +); + +test( + "runs of two specs that overlap on one output folder all finish, and the folder always holds one run's files", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 400); + writeLamp(dir); + const pay = ["from-spec", "Pay.tla", "--out", "shared"]; + const lamp = ["from-spec", "Lamp.tla", "--out", "shared"]; + const alone = {}; + for (const args of [pay, lamp]) { + const solo = join(dir, `${args[1]}-alone`); + const r = node("statechart.mjs", [...args.slice(0, 2), "--out", solo], dir); + assert.equal(r.status, 0, r.stderr); + alone[args[1]] = { + top: readdirSync(solo).sort().join(), + tlc: readdirSync(join(solo, "tlc")).sort().join(), + chart: readFileSync(join(solo, "chart.mmd"), "utf8"), + summary: readFileSync(join(solo, "summary.json"), "utf8"), + }; + } + const shared = join(dir, "shared"); + const seen = new Set(); + let watching = true; + const watch = (async () => { + while (watching) { + try { + seen.add(readdirSync(join(shared, "tlc")).sort().join()); + } catch (e) { + if (e.code !== "ENOENT") throw e; + } + await sleep(5); + } + })(); + const runs = []; + for (let i = 0; i < 6; i++) { + runs.push(startRun(dir, { args: i % 2 ? lamp : pay }).done); + await sleep(150); + } + const finished = await Promise.all(runs); + watching = false; + await watch; + for (const r of finished) assert.equal(r.status, 0, r.stderr); + const runsOf = new Map(Object.entries(alone).map(([spec, a]) => [a.tlc, spec])); + for (const listing of seen) assert.ok(runsOf.has(listing), `tlc/ had ${listing}`); + const last = alone[runsOf.get(readdirSync(join(shared, "tlc")).sort().join())]; + assert.equal(readdirSync(shared).sort().join(), last.top); + assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), last.chart); + assert.equal(readFileSync(join(shared, "summary.json"), "utf8"), last.summary); + assert.equal(existsSync(out), false); + assert.deepEqual(staged(dir), []); + }, +); + +test( + "the output folder model keeps every rule, and breaks one with a shared TLC folder, without each guard, or when a failed rename deletes the folder moved aside", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const model = (ext) => readFileSync(join(scripts, "model", `OutputFolder${ext}`), "utf8"); + const r = node( + "statechart.mjs", + ["from-spec", join(scripts, "model", "OutputFolder.tla"), "--out", join(dir, "out")], + dir, + ); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /\nNo rule is broken\.\n/); + for (const [i, [rule, guard, without, story]] of [ + [ + "ChartsOneSpec", + "/\\ read' = Set(read, r, read[r] \\cup {spec})", + "/\\ read' = [q \\in Runs |-> read[q] \\cup {spec}]", + /You start run 2 in a second shell\..*\n.*Run 2 moves its folder into place\. Now: Two runs going: chart of both specs/, + ], + [ + "ChartsOneSpec", + '/\\ pc[r] = "publish" /\\ spec = snap[r] /\\ ~out.notes', + '/\\ pc[r] = "publish" /\\ ~out.notes', + /You edit the spec\..*\n.*Run 1 moves its folder into place\. Now: One run going: chart of both specs/, + ], + [ + "KeepsYourFiles", + '/\\ pc[r] = "aside" /\\ ~aside[r].notes\n', + '/\\ pc[r] = "aside"\n', + /Run 1 deletes the old folder and finishes\. Now: Done: chart of the spec\n/, + ], + [ + "NeverLosesChart", + " /\\ \\/ /\\ aside[r].exists /\\ ~out.exists\n /\\ out' = aside[r]\n /\\ aside' = Set(aside, r, NoFolder)\n \\/ UNCHANGED <>\n", + " /\\ out' = out\n /\\ aside' = Set(aside, r, IF aside[r].notes THEN aside[r] ELSE NoFolder)\n", + /Run 2 moves the old folder aside\. Now: .*, the old chart moved aside\n.*Run 2 cannot rename a folder and stops\. Now: One run going: no folder\n/, + ], + [ + "NeverLosesChart", + '/\\ pc[r] = "swap" /\\ out.exists /\\ aside[r].exists /\\ ~out.notes', + '/\\ pc[r] = "swap" /\\ out.exists /\\ aside[r].exists', + /You save notes in the folder\..*\n.*Run 2 finds another run's folder in place and deletes the old folder\. Now: Two runs going: your notes\n/, + ], + ].entries()) { + const mutant = join(dir, `${i}-${rule}`); + mkdirSync(mutant); + assert.ok(model(".tla").includes(guard), guard); + writeFileSync(join(mutant, "OutputFolder.tla"), model(".tla").replace(guard, without)); + for (const ext of [".cfg", ".map.mjs"]) + writeFileSync(join(mutant, `OutputFolder${ext}`), model(ext)); + const m = node( + "statechart.mjs", + ["from-spec", "OutputFolder.tla", "--expect-broken", rule], + mutant, + ); + assert.equal(m.status, 0, m.stdout + m.stderr); + assert.match(m.stdout, story); + } + }, +); diff --git a/plugins/pstack/skills/statechart/scripts/test/sync.mmd b/plugins/pstack/skills/statechart/scripts/test/sync.mmd new file mode 100644 index 0000000..c780c2e --- /dev/null +++ b/plugins/pstack/skills/statechart/scripts/test/sync.mmd @@ -0,0 +1,19 @@ +stateDiagram-v2 + state "Notes app" as App { + state "Connection" as Connection { + [*] --> Online + Online --> Offline : the network drops + Offline --> Online : the network returns + } + -- + state "Sync badge" as Sync { + state "Synced" as Synced + state "Waiting to sync" as Pending + [*] --> Synced + Synced --> Pending : you edit a note + Synced --> Pending : the network drops + Pending --> Synced : the server confirms + } + } + [*] --> App + %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline. diff --git a/plugins/pstack/skills/statechart/scripts/test/upload.mmd b/plugins/pstack/skills/statechart/scripts/test/upload.mmd new file mode 100644 index 0000000..dcfafa7 --- /dev/null +++ b/plugins/pstack/skills/statechart/scripts/test/upload.mmd @@ -0,0 +1,16 @@ +stateDiagram-v2 + state "Nothing chosen" as Idle + state "Uploading" as Uploading { + state "Sending the file" as Sending + state "Checking the file" as Checking + [*] --> Sending + Sending --> Checking : the last byte arrives + } + state "Uploaded" as Done + state "Upload failed" as Failed + [*] --> Idle + Idle --> Uploading : you choose a file + Checking --> Done : the check passes + Uploading --> Failed : the network drops + Failed --> Uploading : you retry + Uploading --> Idle : you cancel diff --git a/plugins/pstack/skills/statechart/scripts/tlc-to-chart.mjs b/plugins/pstack/skills/statechart/scripts/tlc-to-chart.mjs new file mode 100644 index 0000000..ccb68fd --- /dev/null +++ b/plugins/pstack/skills/statechart/scripts/tlc-to-chart.mjs @@ -0,0 +1,1347 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { + FULL_LABELS, + GROUP_PALETTE, + LEGEND_ID, + PLAIN_FILL, + checkTlcOutput, + compareCharts, + groupClassDef, + groupColorProblem, + isRecord, + joinLabels, + parseDot, + parseMermaid, + parseTraces, + productOf, + quote, + ruleSentences, + stateKey, + wrap, + STATUS_CLASSES, +} from "./lib.mjs"; + +const USAGE = "Usage: node tlc-to-chart.mjs --dot FILE --map FILE [options]"; +const HELP = `${USAGE} + +Collapses a TLC state graph onto the reader's states and writes a Mermaid stateDiagram-v2. +statechart.mjs runs it once for the overview and once for each bug chart. + + --dot FILE The state graph from TLC -dump dot,actionlabels FILE, for the current code. + Required. + --map FILE The mapping module. Required. It exports state(vars, facts), and + optionally detail(vars, facts), step(action, before, after), + guard(action, before, after), timer(action, before, after), + cause(vars, facts, invariant), group(vars, facts), groupColors, + regionNames, readerActions, and legend. guard is when the step + can happen, in plain words. An edge reads "step [words]" when every + model step behind it has words. timer is how many seconds the code waits + before a timer step, and the step reads "after N s, step". + cause is the hidden reason a bad state is bad: counterexamples with the + same invariant and cause are one bug. When cause returns nothing, the + end state stands in for it. readerActions are the actions the + reader takes. group is the group of a state, below. legend, such as + { works: "Works on main" }, gives the legend's words for the works, + wrong, and fixed statuses. + facts.enabled is the set of actions the state enables. facts.settled is + true when only reader actions are enabled, so nothing else happens until + the reader acts. + --traces FILE TLC output that holds counterexamples. Repeat it. TLC -continue gives one + per bad state. It refuses a file from a TLC run that stopped early or hit + an error other than a broken rule. + --fixed-dot FILE The state graph of the model with the open fix. Each bug's steps replay + on it. + --fixed-traces FILE TLC output for the same invariants on the fixed model. Repeat it. It marks + states fixed or still wrong, and adds the bugs that only the fixed model + has after the current model's bugs. + --against-dot FILE The state graph of the spec that the current code must follow. + Each bug's steps replay on it. Do not use it with --fixed-dot. + --against-traces FILE TLC output for the spec. Repeat it. Its bugs are drawn wrong, and the + bugs that only the spec has number after the code's bugs. + --label TEXT The name of the second model in the chart and the stories. Default: + "the fix" with --fixed-dot, "the spec" with --against-dot. + --spec FILE A TLA+ file whose \\* comment lines above a rule are the rule's sentence in + the stories. Repeat it. The first file that has a sentence wins. + --view NAME customer (state labels, the default), developer (detail labels), or + layered (detail inside state). + --cover Keep only the shortest path to each settled reader state, plus the + counterexample paths. + --highlight N Draw bug N as a numbered path. Default 1. 0 draws none. + --path-only Draw only the highlighted path. + --full-labels Write each label on an edge in full, joined by " or ", and mark the chart + "${FULL_LABELS}". Default: write the leading words that the labels + share once, as in "you tap Pay or Back". + --flat Draw every state on its own, with no boxes. + --regions When every state reads "context: progress", draw two parallel regions, + context and progress, when they are smaller and proved. They draw the + steps of the shortest way to each region state, of each bug path, and + the nearest step with each label. + Otherwise draw the chart the other flags ask for, and print why. + Customer view, --highlight 0, no --flat or --path-only. + --proof FILE With --regions, write the flat chart that the proof compares with. + --regions-all FILE With --regions, also write the regions with every step. + --title TEXT The chart title. + --out FILE The Mermaid chart. Default: stdout. + --story FILE The reader stories, one per bug. + --summary FILE The counts for the chart and the bugs, as JSON. + -h, --help Print this help, and exit 0. + +Each bug replays on the second model by its screens and step words, which are what the reader +sees. The replay takes the second model's steps that keep the screen and that the reader does not +take. A bug step that the reader does not see must keep the screen on the second model too, unless +the second model cannot take it. The replay stops at the first step that the second model shows +the reader differently. There the chart draws "with LABEL, STEP" (or "in LABEL, STEP" for +--against-dot) to where the second model goes, or a loop "... STEP cannot happen" when it cannot +take a step that the reader sees. + +The second model may have other variables and actions than the current model. Variables and +record fields that one start state has and the other does not are left out when the two models +are compared. The second model's states take the current model's start values for the variables +it does not have, so one mapping draws both. A step of an action that the second model does not +have is skipped when it changes no shared variable, and matches any step to the same shared state +when it does. + +In the customer view, states whose labels read "context: progress" with the same context sit in +one box named context, and each shows only its progress. A context with one state stays a plain +state. An arrow from a box's border stands for the same step from every state in the box: it is +drawn only when every state in the box takes that step, with the same labels, to the same state. +The layered view puts each detail state in a box named by its state label. + +With --regions, each "context: progress" label is one state in the context region and one in the +progress region. A step that happens beside only some states of the other region gets a guard: +"[when A or B]" or "[unless A or B]", whichever is shorter, and a "%% when [not] ids" line under it. +The mapping's guard words, when the step has them, replace "when A or B" in the label. +The proof combines the regions with every step as compare-charts.mjs does, and needs exactly the +flat chart's states, start states, and labeled steps. Each state's color must be the worse color +of its two region states. Then the drawn arrows, combined, must make only steps of the flat chart: +a step that they make wrongly gets back every arrow that the full regions take there. +compare-charts.mjs on the --regions-all and --proof files repeats the first proof. +mapping.regionNames = [context, progress] names the two regions. + +Outside regions, two states have at most one arrow. Its lines are the numbered steps, then the +current model's steps, then the second model's steps, with a blank line between each group. + +Without group(), the legend shows only the statuses the chart uses. A chart with a second model +has the line "%% second model (fix|against): LABEL" right after stateDiagram-v2. Each chart +starts with front matter that keeps Mermaid from shrinking it to the page or rewrapping its labels. + +Groups: group(vars, facts) returns the name of the group that a model state is in, such as +"Toast shown", or nothing. A chart state is in a group when all its model states are in it. A +region state is in a group when every state that it is part of is in it. Each group fills its +states with one color, and the legend lists only the groups that the chart draws: first the +groups in groupColors, in that order, then the others from A to Z. groupColors, such as +{ "Toast shown": "#d6eaf8" }, gives a group a light "#rgb" or "#rrggbb" color. A group with no +color, or null, takes the next free palette color: light blue, orange, purple, yellow, teal, tan, +then pink. A state in no group, or in more than one, is gray. summary.json counts the drawn +states of each group in chartGroups, and lists the chart states with more than one in mixedGroups. +TLC decides the status, and a group cannot change it. On top of the group color, a state that +breaks a rule has a thick red border, and a fixed state or a state only in the second model a +thick green border. The legend then shows that border. A thick blue border marks the bug path on +states that work. On other states of the path, the numbered arrows show it. A group cannot have +the name of a status, such as "Breaks a rule". + +Writes only the files that --out, --story, and --summary name. + +Exit codes + 0 It wrote the chart. + 1 It cannot use an input: a mapping without state(), TLC output that it refuses, or a + --highlight with no such bug. The error says why. + 2 A bad argument.`; +const more = "Run node tlc-to-chart.mjs --help for the flags."; + +const args = { + traces: [], + fixedTraces: [], + againstTraces: [], + specs: [], + view: "customer", + highlight: 1, +}; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + const next = () => process.argv[++i]; + if (a === "-h" || a === "--help") { + console.log(HELP); + process.exit(0); + } else if (a === "--dot") args.dot = next(); + else if (a === "--map") args.map = next(); + else if (a === "--traces") args.traces.push(next()); + else if (a === "--fixed-dot") args.fixedDot = next(); + else if (a === "--fixed-traces") args.fixedTraces.push(next()); + else if (a === "--against-dot") args.againstDot = next(); + else if (a === "--against-traces") args.againstTraces.push(next()); + else if (a === "--label") args.label = next(); + else if (a === "--spec") args.specs.push(next()); + else if (a === "--view") args.view = next(); + else if (a === "--highlight") args.highlight = Number(next()); + else if (a === "--path-only") args.pathOnly = true; + else if (a === "--full-labels") args.fullLabels = true; + else if (a === "--flat") args.flat = true; + else if (a === "--regions") args.regions = true; + else if (a === "--proof") args.proof = next(); + else if (a === "--regions-all") args.regionsAll = next(); + else if (a === "--cover") args.cover = true; + else if (a === "--title") args.title = next(); + else if (a === "--out") args.out = next(); + else if (a === "--story") args.story = next(); + else if (a === "--summary") args.summary = next(); + else { + console.error(`unknown argument ${a}. ${more}`); + process.exit(2); + } +} +const fail = (message) => { + console.error(`${message} ${more}`); + process.exit(2); +}; +if (!args.dot || !args.map) fail(USAGE); +if (!["customer", "developer", "layered"].includes(args.view)) + fail(`--view must be customer, developer, or layered, not ${args.view}.`); +if (args.fixedDot && args.againstDot) fail("use --fixed-dot or --against-dot, not both."); +if (args.fixedTraces.length && !args.fixedDot) fail("--fixed-traces needs --fixed-dot."); +if (args.againstTraces.length && !args.againstDot) fail("--against-traces needs --against-dot."); +if (args.label !== undefined && !args.fixedDot && !args.againstDot) + fail("--label names the second model; it needs --fixed-dot or --against-dot."); +if (args.proof && !args.regions) fail("--proof writes the regions proof; it needs --regions."); +if (args.regionsAll && !args.regions) + fail("--regions-all writes every regions arrow; it needs --regions."); +if ( + args.regions && + (args.view !== "customer" || args.flat || args.pathOnly || args.highlight !== 0) +) + fail( + "--regions draws the whole customer chart: use --highlight 0, without --flat or --path-only.", + ); + +const role = args.fixedDot ? "fix" : args.againstDot ? "against" : null; +const L = args.label ?? (role === "fix" ? "the fix" : "the spec"); +const within = role === "fix" ? "with" : "in"; +const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1); + +const mapping = await import(pathToFileURL(path.resolve(args.map)).href); +if (typeof mapping.state !== "function") + throw new Error("the mapping module must export state(vars)"); +if (args.view !== "customer" && typeof mapping.detail !== "function") + throw new Error(`--view ${args.view} needs detail(vars) in the mapping module`); +const SEP = "\u0000"; +const edgeId = (from, to) => `${from}${SEP}${SEP}${to}`; + +const rules = new Map(); +for (const f of args.specs) + for (const [name, sentence] of ruleSentences(fs.readFileSync(f, "utf8"))) + if (!rules.has(name)) rules.set(name, sentence); + +const readerActions = new Set(mapping.readerActions ?? []); +const factsFrom = (enabled) => ({ + enabled, + settled: [...enabled].every((a) => readerActions.has(a)), +}); +function keyOf(vars, facts) { + if (args.view === "developer") return String(mapping.detail(vars, facts)); + const outer = mapping.state(vars, facts); + if (typeof outer !== "string") + throw new Error(`state() must return a string; got ${JSON.stringify(outer)}`); + return args.view === "layered" ? `${outer}${SEP}${mapping.detail(vars, facts)}` : outer; +} +function stepLabel(action, before, after, view = args.view) { + const said = typeof mapping.step === "function" ? mapping.step(action, before, after) : action; + const named = view === "customer" || said === action ? said : `${said} [${action}]`; + const seconds = typeof mapping.timer === "function" ? mapping.timer(action, before, after) : null; + if (seconds === undefined || seconds === null || seconds === "" || !named) return named; + if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) + throw new Error( + `timer() must return a number of seconds above 0, or nothing; got ${JSON.stringify(seconds)} for ${action}`, + ); + return `after ${seconds} s, ${named}`; +} +function stepGuard(action, before, after) { + if (typeof mapping.guard !== "function") return ""; + return String(mapping.guard(action, before, after) ?? "") + .replace(/[[\]]/g, "") + .trim(); +} +function guarded(guards, label) { + const words = guards?.get(label); + return words && !words.has("") ? `${label} [${[...words].sort().join(" or ")}]` : label; +} +const addGuard = (guards, label, words) => { + if (!guards.has(label)) guards.set(label, new Set()); + guards.get(label).add(words); +}; + +function lacking(a, b, prefix = []) { + return Object.keys(a).flatMap((k) => + !(k in b) + ? [[...prefix, k]] + : isRecord(a[k]) && isRecord(b[k]) + ? lacking(a[k], b[k], [...prefix, k]) + : [], + ); +} +const holder = (o, p) => p.slice(0, -1).reduce((x, k) => (isRecord(x?.[k]) ? x[k] : null), o); +function omit(vars, paths) { + if (!paths.length) return vars; + const out = structuredClone(vars); + for (const p of paths) { + const o = holder(out, p); + if (o) delete o[p.at(-1)]; + } + return out; +} +function fill(vars, paths, from) { + if (!paths.length) return vars; + const out = structuredClone(vars); + for (const p of paths) { + const [o, f] = [holder(out, p), holder(from, p)]; + if (o && f && p.at(-1) in f) o[p.at(-1)] = structuredClone(f[p.at(-1)]); + } + return out; +} + +function modelOf(graph, lookup = (vars) => vars) { + const byId = new Map([...graph.nodes.keys()].map((id) => [id, new Set()])); + for (const e of [...graph.edges, ...graph.loops]) byId.get(e.from).add(e.action); + const index = new Map( + [...graph.nodes.values()].map((n) => [stateKey(n.raw ?? n.vars), byId.get(n.id)]), + ); + const facts = (vars) => factsFrom(index.get(stateKey(lookup(vars))) ?? new Set()); + return { + graph, + facts, + key: (vars) => keyOf(vars, facts(vars)), + customer: (vars) => String(mapping.state(vars, facts(vars))), + }; +} + +function quotient(m) { + const { graph } = m; + const keys = new Map(); + for (const n of graph.nodes.values()) keys.set(n.id, m.key(n.vars)); + const nodes = new Set(keys.values()); + const initial = new Set( + [...graph.nodes.values()].filter((n) => n.init).map((n) => keys.get(n.id)), + ); + const edges = new Map(); + for (const e of graph.edges) { + const from = keys.get(e.from); + const to = keys.get(e.to); + if (from === to) continue; + const id = edgeId(from, to); + if (!edges.has(id)) edges.set(id, { from, to, counts: new Map(), guards: new Map() }); + const [before, after] = [graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars]; + const label = stepLabel(e.action, before, after); + if (!label) continue; + edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); + addGuard(edges.get(id).guards, label, stepGuard(e.action, before, after)); + } + for (const e of edges.values()) + e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); + const settled = new Set( + [...graph.nodes.values()].filter((n) => m.facts(n.vars).settled).map((n) => keys.get(n.id)), + ); + return { nodes, initial, edges, keys, settled }; +} + +const graph = parseDot(fs.readFileSync(args.dot, "utf8")); +const main = modelOf(graph); +const now = quotient(main); +const mainInit = [...graph.nodes.values()].find((n) => n.init).vars; + +const secondDot = args.fixedDot ?? args.againstDot; +const secondGraph = secondDot ? parseDot(fs.readFileSync(secondDot, "utf8")) : null; +const secondInit = secondGraph && [...secondGraph.nodes.values()].find((n) => n.init).vars; +const lacks = secondGraph ? lacking(mainInit, secondInit) : []; +const extra = secondGraph ? lacking(secondInit, mainInit) : []; +const secondFilled = secondGraph && { + ...secondGraph, + nodes: new Map( + [...secondGraph.nodes].map(([id, n]) => [ + id, + { ...n, raw: n.vars, vars: fill(n.vars, lacks, mainInit) }, + ]), + ), +}; +const second = secondFilled && modelOf(secondFilled, (vars) => omit(vars, lacks)); +const secondQ = second && quotient(second); + +function readerPath(trace, key, labelOf = stepLabel) { + const out = [{ key: key(trace.steps[0].vars), label: null, modelStep: 0 }]; + for (let i = 1; i < trace.steps.length; i++) { + const { action } = trace.steps[i]; + const k = key(trace.steps[i].vars); + if (k === out[out.length - 1].key && !readerActions.has(action)) continue; + const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars]; + const label = labelOf(action, before, after) || action; + const guard = stepGuard(action, before, after); + out.push({ key: k, label, guard, action, modelStep: i }); + } + return out; +} + +const readerLabel = (action, before, after) => stepLabel(action, before, after, "customer"); +function replaySecond(trace) { + if (!second) return null; + const shared = (vars) => stateKey(omit(vars, lacks)); + const sharedOf = (n) => stateKey(omit(n.raw, extra)); + const out = new Map(); + for (const e of [...second.graph.edges, ...second.graph.loops]) { + if (!out.has(e.from)) out.set(e.from, []); + out.get(e.from).push(e); + } + const actions = new Set([...second.graph.edges, ...second.graph.loops].map((e) => e.action)); + const nodeOf = (id) => second.graph.nodes.get(id); + const screen = new Map( + [...second.graph.nodes.values()].map((n) => [n.id, second.customer(n.vars)]), + ); + const words = (e) => readerLabel(e.action, nodeOf(e.from).vars, nodeOf(e.to).vars) || e.action; + const settle = (ids) => { + const all = new Set(ids); + for (const id of all) + for (const e of out.get(id) ?? []) + if (!readerActions.has(e.action) && screen.get(e.to) === screen.get(id)) all.add(e.to); + return all; + }; + const seen = readerPath(trace, main.customer, readerLabel); + const shownAt = new Map(seen.map((p, j) => [p.modelStep, j])); + let here = settle( + [...second.graph.nodes.values()] + .filter((n) => n.init && screen.get(n.id) === seen[0].key) + .map((n) => n.id), + ); + if (here.size === 0) return { reason: "starts on another screen" }; + const passed = new Set(here); + let j = 0; + for (let i = 1; i < trace.steps.length; i++) { + const { action } = trace.steps[i]; + const hidden = !shownAt.has(i); + if (!hidden) j = shownAt.get(i); + const want = seen[j]; + const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars]; + const known = actions.has(action); + if (!known && (hidden || shared(before) === shared(after))) continue; + const tries = [...here].flatMap((id) => + (out.get(id) ?? []).filter((e) => + known ? e.action === action : sharedOf(nodeOf(e.to)) === shared(after), + ), + ); + if (hidden && tries.length === 0) continue; + const same = known + ? tries.filter((e) => screen.get(e.to) === want.key && (hidden || words(e) === want.label)) + : tries; + if (same.length) { + here = settle(same.map((e) => e.to)); + for (const id of here) passed.add(id); + continue; + } + const outcomes = new Map(); + for (const e of tries) { + const id = `${screen.get(e.to)}${SEP}${words(e)}`; + outcomes.set(id, [...(outcomes.get(id) ?? []), e]); + } + const taken = [...outcomes.values()].sort((a, b) => b.length - a.length)[0]?.[0]; + const from = main.key(before); + const said = hidden ? readerLabel(action, before, after) || action : want.label; + const at = { step: j, said, from, hidden }; + if (!taken) + return { + ...at, + key: from, + label: stepLabel(action, before, after) || action, + disabled: true, + }; + const [b, a] = [nodeOf(taken.from).vars, nodeOf(taken.to).vars]; + return { ...at, key: second.key(a), label: stepLabel(taken.action, b, a) || taken.action }; + } + const bad = new Set( + rawSecondTraces + .filter((t) => t.invariant === trace.invariant) + .map((t) => stateKey(t.steps[t.steps.length - 1].vars)), + ); + const ruleHolds = + secondTraceFiles.length > 0 && ![...passed].some((id) => bad.has(stateKey(nodeOf(id).vars))); + return { sameEnd: true, ruleHolds }; +} + +function readTraces(files, paths = []) { + return files.flatMap((f) => { + const text = fs.readFileSync(f, "utf8"); + checkTlcOutput(text, f); + return parseTraces(text).map((t) => ({ + ...t, + steps: t.steps.map((s) => ({ ...s, vars: fill(s.vars, paths, mainInit) })), + })); + }); +} +// With -continue, TLC reports every bad state, including states after the first bad one. +function firstBad(raw) { + const badStates = new Map(); + for (const t of raw) { + if (!badStates.has(t.invariant)) badStates.set(t.invariant, new Set()); + badStates.get(t.invariant).add(stateKey(t.steps[t.steps.length - 1].vars)); + } + const cuts = []; + const seen = new Set(); + for (const t of raw) { + const first = t.steps.findIndex((s) => badStates.get(t.invariant).has(stateKey(s.vars))); + const cut = { invariant: t.invariant, steps: t.steps.slice(0, first + 1) }; + const id = `${t.invariant}${SEP}${stateKey(cut.steps[cut.steps.length - 1].vars)}`; + if (seen.has(id)) continue; + seen.add(id); + cuts.push(cut); + } + return cuts; +} +const rawTraces = readTraces(args.traces); +const secondTraceFiles = role === "fix" ? args.fixedTraces : args.againstTraces; +const rawSecondTraces = readTraces(secondTraceFiles, lacks); +const traces = firstBad(rawTraces); +const secondTraces = firstBad(rawSecondTraces); +function group(cuts, m) { + const byId = new Map(); + for (const trace of cuts) { + const rp = readerPath(trace, m.customer); + const last = trace.steps[trace.steps.length - 1].vars; + const said = + typeof mapping.cause === "function" + ? mapping.cause(last, m.facts(last), trace.invariant) + : null; + const cause = said === undefined || said === null ? null : String(said); + const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; + const prev = byId.get(id); + if ( + !prev || + rp.length < prev.steps || + (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length) + ) { + byId.set(id, { + id, + cause, + steps: rp.length, + trace, + invariant: trace.invariant, + count: (prev?.count ?? 0) + 1, + }); + } else prev.count++; + } + return byId; +} +const byOrder = (a, b) => + a.steps - b.steps || + a.trace.steps.length - b.trace.steps.length || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); +const inSecond = (l) => `${within} ${L}, ${l}`; +const modelEdges = new Map(now.edges); +if (secondQ) + for (const [id, e] of secondQ.edges) + if (!modelEdges.has(id)) + modelEdges.set(id, { + ...e, + labels: new Set([...e.labels].map(inSecond)), + guards: new Map([...e.guards].map(([l, words]) => [inSecond(l), words])), + secondOnly: true, + }); +function divergence(s) { + if (s?.key === undefined) return s; + const plain = edgeId(s.from, s.key); + const text = inSecond(`${s.label}${s.disabled ? " cannot happen" : ""}`); + const drawn = !s.disabled && [s.label, text].some((l) => modelEdges.get(plain)?.labels.has(l)); + return { ...s, text, edge: drawn ? plain : `${plain}${SEP}second` }; +} +const withPath = (c, m) => { + const path = readerPath(c.trace, m.key); + return { ...c, path, end: path[path.length - 1].key }; +}; +const classesById = group(traces, main); +const mainClasses = [...classesById.values()] + .sort(byOrder) + .map((c) => ({ ...withPath(c, main), second: divergence(replaySecond(c.trace)) })); +const introduced = second + ? [...group(secondTraces, second).values()] + .filter((c) => !classesById.has(c.id)) + .sort(byOrder) + .map((c) => ({ ...withPath(c, second), second: null, introduced: true })) + : []; +const classes = [...mainClasses, ...introduced]; + +const diverges = (c) => c.second?.key !== undefined; +function badKeys(m, keys, raw) { + const g = m.graph; + const bad = new Set(raw.map((t) => stateKey(t.steps[t.steps.length - 1].vars))); + const out = new Set(raw.map((t) => m.key(t.steps[t.steps.length - 1].vars))); + const ok = (id) => !bad.has(stateKey(g.nodes.get(id).vars)); + const next = new Map(); + for (const e of g.edges) { + if (!next.has(e.from)) next.set(e.from, []); + next.get(e.from).push(e.to); + } + const clean = new Set([...g.nodes.values()].filter((n) => n.init && ok(n.id)).map((n) => n.id)); + const queue = [...clean]; + while (queue.length) { + for (const to of next.get(queue.shift()) ?? []) { + if (clean.has(to) || !ok(to)) continue; + clean.add(to); + queue.push(to); + } + } + const reached = new Set([...clean].map((id) => keys.get(id))); + for (const k of keys.values()) if (!reached.has(k)) out.add(k); + return out; +} +const badNow = badKeys(main, now.keys, rawTraces); +const badSecond = + second && secondTraceFiles.length > 0 + ? badKeys(second, secondQ.keys, rawSecondTraces) + : role === "fix" + ? badNow + : new Set(); +const allNodes = new Set([...now.nodes, ...(secondQ?.nodes ?? [])]); +const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]); +const onlySecond = (k) => second && !now.nodes.has(k); +const status = (k) => { + if (role === "against") + return badNow.has(k) || badSecond.has(k) ? "wrong" : onlySecond(k) ? "fixed" : "works"; + if (role === "fix") + return badSecond.has(k) ? "wrong" : badNow.has(k) || onlySecond(k) ? "fixed" : "works"; + return badNow.has(k) ? "wrong" : "works"; +}; + +const chosen = args.highlight > 0 ? classes[args.highlight - 1] : null; +if (args.highlight > 0 && classes.length > 0 && !chosen) + throw new Error(`--highlight ${args.highlight}: only ${classes.length} classes`); +const onPath = new Set(chosen ? chosen.path.map((p) => p.key) : []); +if (chosen && diverges(chosen)) onPath.add(chosen.second.key); + +const edgesAlong = (c) => + c.path.slice(1).map((p, i) => ({ + id: edgeId(c.path[i].key, p.key), + from: c.path[i].key, + to: p.key, + label: p.label, + guard: p.guard, + })); +function chartEdges() { + const edges = new Map(modelEdges); + for (const { second: s } of classes.filter(diverges)) { + if (modelEdges.has(s.edge)) continue; + if (!edges.has(s.edge)) + edges.set(s.edge, { from: s.from, to: s.key, labels: new Set(), secondOnly: true }); + edges.get(s.edge).labels.add(s.text); + } + const pathNumbers = new Map(); + (chosen ? edgesAlong(chosen) : []).forEach((step, i) => { + if (!pathNumbers.has(step.id)) pathNumbers.set(step.id, []); + const shown = step.guard ? `${step.label} [${step.guard}]` : step.label; + pathNumbers.get(step.id).push({ n: i + 1, label: step.label, shown }); + if (!edges.has(step.id)) + edges.set(step.id, { from: step.from, to: step.to, labels: new Set([step.label]) }); + }); + if (!args.cover) return { edges, pathNumbers, coverEdges: null }; + + const coverEdges = new Set(); + const bfs = (q, targets) => { + const parent = new Map([...q.initial].map((k) => [k, null])); + const queue = [...q.initial]; + const outEdges = new Map(); + for (const [id, e] of q.edges) { + if (!outEdges.has(e.from)) outEdges.set(e.from, []); + outEdges.get(e.from).push([id, e]); + } + while (queue.length) { + const k = queue.shift(); + for (const [id, e] of outEdges.get(k) ?? []) { + if (parent.has(e.to)) continue; + parent.set(e.to, id); + queue.push(e.to); + } + } + for (const t of targets) { + for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) coverEdges.add(id); + } + }; + bfs(now, now.settled); + if (secondQ) + bfs( + secondQ, + [...secondQ.settled].filter((k) => !now.nodes.has(k)), + ); + const pathLabels = new Map(); + for (const c of classes) { + for (const step of edgesAlong(c)) { + coverEdges.add(step.id); + pathLabels.set(step.id, (pathLabels.get(step.id) ?? new Set()).add(step.label)); + } + if (diverges(c)) coverEdges.add(c.second.edge); + } + for (const id of coverEdges) { + if (edges.has(id)) continue; + const [from, to] = id.split(`${SEP}${SEP}`); + edges.set(id, { from, to, labels: pathLabels.get(id) }); + } + return { edges, pathNumbers, coverEdges }; +} +const { edges, pathNumbers, coverEdges } = chartEdges(); + +const LEGEND = { + works: ["legendWorks", mapping.legend?.works ?? (role === "fix" ? "Works today" : "Works")], + wrong: [ + "legendWrong", + mapping.legend?.wrong ?? (role === "fix" ? `Still wrong with ${L}` : "Breaks a rule"), + ], + fixed: [ + "legendFixed", + mapping.legend?.fixed ?? (role === "fix" ? `Fixed or new with ${L}` : `Only in ${L}`), + ], +}; + +const grouping = typeof mapping.group === "function"; +const groupsOf = new Map(); +const groupOf = (k) => (groupsOf.get(k)?.size === 1 ? [...groupsOf.get(k)][0] : ""); +const groupNames = []; +const fills = new Map(); +if (grouping) { + const add = (k, vars, m) => { + const g = mapping.group(vars, m.facts(vars)); + if (g !== undefined && g !== null && typeof g !== "string") + throw new Error(`group() must return a group name or nothing; got ${JSON.stringify(g)}`); + if (!groupsOf.has(k)) groupsOf.set(k, new Set()); + groupsOf.get(k).add((g ?? "").replace(/\s+/g, " ").trim()); + }; + for (const n of graph.nodes.values()) add(now.keys.get(n.id), n.vars, main); + if (second) + for (const n of second.graph.nodes.values()) add(secondQ.keys.get(n.id), n.vars, second); + + const given = mapping.groupColors ?? {}; + if (!isRecord(given)) + throw new Error("groupColors must be an object from a group name to a color such as #d6eaf8"); + const statusNames = new Set( + [ + "Works", + "Works today", + "Breaks a rule", + "Still wrong", + "Fixed", + ...Object.values(LEGEND).map(([, t]) => t), + ].map((t) => t.toLowerCase()), + ); + const found = new Set([...groupsOf.values()].flatMap((gs) => [...gs]).filter(Boolean)); + for (const g of found) + if (statusNames.has(g.toLowerCase())) + throw new Error( + `group "${g}" has the name of a status; TLC decides the status, so name the group for what the reader sees`, + ); + groupNames.push( + ...Object.keys(given).filter((g) => found.has(g)), + ...[...found].filter((g) => !Object.hasOwn(given, g)).sort(), + ); + for (const [g, color] of Object.entries(given)) { + if (color === undefined || color === null || color === "") continue; + const why = groupColorProblem(color); + if (why) throw new Error(`groupColors["${g}"] ${JSON.stringify(color)} ${why}`); + if (found.has(g)) fills.set(g, color.toLowerCase()); + } + const free = GROUP_PALETTE.filter((c) => ![...fills.values()].includes(c)); + for (const g of groupNames) { + if (fills.has(g)) continue; + if (!free.length) + throw new Error( + `the mapping has ${groupNames.length} groups, more than the ${GROUP_PALETTE.length} colors of the palette; give the others a color in groupColors`, + ); + fills.set(g, free.shift()); + } +} +const mixedGroups = [...groupsOf].filter(([, gs]) => gs.size > 1).map(([k]) => k); + +const BORDER = { works: "", wrong: "Wrong", fixed: "Fixed" }; +const classFor = (g, s, path) => + grouping + ? `${g ? `group${groupNames.indexOf(g)}` : "plain"}${BORDER[s] || (path ? "Path" : "")}` + : `${s}${path ? "Path" : ""}`; +const drawnState = (g, s, path = false) => ({ group: g, status: s, cls: classFor(g, s, path) }); +const groupCounts = (drawn) => + groupNames + .map((g) => ({ + name: g, + color: fills.get(g), + states: drawn.filter((d) => d.group === g).length, + })) + .filter((g) => g.states > 0); + +// Mermaid shrinks a chart to the page width unless useMaxWidth is false, and rewraps labels that +// wrap() already wrapped unless markdownAutoWrap is false. +const frontMatter = (title) => [ + "---", + ...(title ? [`title: ${JSON.stringify(title)}`] : []), + "config:", + " markdownAutoWrap: false", + " state:", + " useMaxWidth: false", + "---", +]; +function head(drawn) { + const out = [...frontMatter(args.title), "stateDiagram-v2"]; + if (role) out.push(` %% second model (${role}): ${L}`); + if (args.fullLabels) out.push(` ${FULL_LABELS}`); + const used = Object.keys(LEGEND).filter((s) => drawn.some((d) => d.status === s)); + const legend = grouping + ? [ + ...groupCounts(drawn).map(({ name }) => { + const i = groupNames.indexOf(name); + return [`legendGroup${i}`, name, `group${i}`]; + }), + ...used.filter((s) => BORDER[s]).map((s) => [...LEGEND[s], `plain${BORDER[s]}`]), + ] + : used.map((s) => [...LEGEND[s], s]); + if (grouping) { + const styleClasses = new Set([...drawn.map((d) => d.cls), ...legend.map(([, , c]) => c)]); + const bases = [["plain", PLAIN_FILL], ...groupNames.map((g, i) => [`group${i}`, fills.get(g)])]; + for (const [base, fill] of bases) + for (const border of ["", "Wrong", "Fixed", "Path"]) + if (styleClasses.has(base + border)) out.push(` ${groupClassDef(base, fill, border)}`); + } else for (const c of STATUS_CLASSES) out.push(` ${c}`); + if (legend.length) { + out.push(` state "Legend" as ${LEGEND_ID} {`); + for (const [id, text] of legend) out.push(` state "${quote(text)}" as ${id}`); + out.push(" }"); + for (const [id, , c] of legend) out.push(` class ${id} ${c}`); + } + return out; +} + +function regionsChart({ states, starts, edges, classes, proofPath, allPath }) { + const norm = (s) => s.replaceAll(";", ",").replace(/\s+/g, " ").trim(); + const split = (k) => { + const at = k.indexOf(": "); + return at > 0 && at + 2 < k.length ? [norm(k.slice(0, at)), norm(k.slice(at + 2))] : null; + }; + const nodes = [...states].sort(); + if (!nodes.every(split)) return null; + const steps = []; + for (const e of edges.values()) { + if (e.labels.size === 0) return { why: "a step has no label" }; + for (const l of e.labels) + steps.push({ + from: split(e.from), + to: split(e.to), + label: norm(l), + words: e.guards?.get(l) ?? new Set([""]), + }); + } + const bar = (p) => p.join(" | "); + const flat = { + Main: { + states: new Set(nodes.map((k) => bar(split(k)))), + initial: new Set(starts.map((k) => bar(split(k)))), + transitions: steps.map((s) => ({ from: bar(s.from), to: bar(s.to), label: s.label })), + }, + }; + const ids = new Map([...flat.Main.states].map((s, i) => [s, `p${i}`])); + const proof = [ + ...frontMatter(), + "stateDiagram-v2", + ...[...ids].map(([s, id]) => ` state "${quote(s)}" as ${id}`), + ...[...flat.Main.initial].map((s) => ` [*] --> ${ids.get(s)}`), + ...steps.map((s) => ` ${ids.get(bar(s.from))} --> ${ids.get(bar(s.to))} : ${s.label}`), + ]; + if (proofPath) fs.writeFileSync(proofPath, proof.join("\n") + "\n"); + + const beside = [new Map(), new Map()]; + for (const k of nodes) { + const p = split(k); + for (const i of [0, 1]) { + if (!beside[i].has(p[i])) beside[i].set(p[i], new Set()); + beside[i].get(p[i]).add(p[1 - i]); + } + } + const size = beside[0].size + beside[1].size; + if (size >= nodes.length) + return { why: `${size} region states are not fewer than the ${nodes.length} flat states` }; + + const rank = ["works", "fixed", "wrong"]; + const regionStatus = [new Map(), new Map()]; + for (const k of nodes) { + const p = split(k); + for (const i of [0, 1]) { + const prev = regionStatus[i].get(p[i]); + if (!prev || rank.indexOf(status(k)) < rank.indexOf(prev)) + regionStatus[i].set(p[i], status(k)); + } + } + for (const k of nodes) { + const p = split(k); + const shown = rank[Math.max(...[0, 1].map((i) => rank.indexOf(regionStatus[i].get(p[i]))))]; + if (shown !== status(k)) + return { + why: `"${k}" is "${LEGEND[status(k)][1]}", and its two region states would show "${LEGEND[shown][1]}"`, + }; + } + + const moves = [new Map(), new Map()]; + for (const s of steps) + for (const i of [0, 1]) { + const id = [s.from[i], s.label, s.to[i]].join(SEP); + if (!moves[i].has(id)) + moves[i].set(id, { + from: s.from[i], + label: s.label, + to: s.to[i], + beside: new Set(), + guards: new Map(), + }); + moves[i].get(id).beside.add(s.from[1 - i]); + for (const w of s.words) addGuard(moves[i].get(id).guards, s.label, norm(w)); + } + for (const m of moves) + for (const [id, x] of m) + if ( + x.from === x.to && + ![...m.values()].some((y) => y.from === x.from && y.label === x.label && y.to !== x.from) + ) + m.delete(id); + const rid = beside.map((m, i) => new Map([...m.keys()].sort().map((s, j) => [s, `r${i}s${j}`]))); + const regionGroup = [new Map(), new Map()]; + for (const k of nodes) { + const p = split(k); + for (const i of [0, 1]) { + const prev = regionGroup[i].get(p[i]); + regionGroup[i].set(p[i], prev === undefined || prev === groupOf(k) ? groupOf(k) : ""); + } + } + const drawn = rid.map((m, i) => + [...m.keys()].map((s) => drawnState(regionGroup[i].get(s), regionStatus[i].get(s))), + ); + const draw = (only) => { + const out = head(drawn.flat()); + out.push(` state "At the same time" as regions {`); + let count = 0; + for (const i of [0, 1]) { + if (i) out.push(" --"); + const name = mapping.regionNames?.[i]; + const pad = name ? " " : " "; + if (name) out.push(` state "${quote(wrap(name))}" as region${i} {`); + for (const s of new Set(starts.map((k) => split(k)[i]))) + out.push(`${pad}[*] --> ${rid[i].get(s)}`); + for (const [s, id] of rid[i]) out.push(`${pad}state "${quote(wrap(s))}" as ${id}`); + const shownMoves = [...moves[i]] + .filter(([id]) => !only || only[i].has(id)) + .map(([, m]) => m) + .sort((a, b) => a.from.localeCompare(b.from) || a.label.localeCompare(b.label)); + for (const m of shownMoves) { + const all = beside[i].get(m.from); + const not = [...all].filter((s) => !m.beside.has(s)).sort(); + const yes = [...m.beside].sort(); + const unless = not.length > 0 && not.length < yes.length; + const guard = not.length === 0 ? null : unless ? not : yes; + const worded = guarded(m.guards, m.label); + const text = + worded !== m.label || !guard + ? worded + : `${m.label} [${unless ? "unless" : "when"} ${guard.join(" or ")}]`; + out.push(`${pad}${rid[i].get(m.from)} --> ${rid[i].get(m.to)} : ${wrap(text, 30)}`); + if (guard) + out.push( + `${pad}%% when ${unless ? "not " : ""}${guard.map((s) => rid[1 - i].get(s)).join(", ")}`, + ); + count++; + } + if (name) out.push(" }"); + } + out.push(" }"); + for (const i of [0, 1]) + [...rid[i].values()].forEach((id, j) => out.push(` class ${id} ${drawn[i][j].cls}`)); + return { mermaid: out.join("\n") + "\n", count }; + }; + + const all = draw(null); + const differences = compareCharts(parseMermaid(all.mermaid).regions, flat); + if (differences.length) { + const n = differences.reduce((sum, [, xs]) => sum + xs.length, 0); + return { + why: `the regions do not combine into the flat chart, with ${n} difference(s), such as ${differences[0][0]}: ${differences[0][1][0]}`, + }; + } + if (allPath) fs.writeFileSync(allPath, all.mermaid); + + const parts = new Map(nodes.map((k) => [bar(split(k)), split(k)])); + const key = (from, label, to) => `${from} --> ${to} : ${label}`; + const real = new Set(steps.map((s) => key(bar(s.from), s.label, bar(s.to)))); + const next = new Map(); + for (const s of steps) { + if (!next.has(bar(s.from))) next.set(bar(s.from), []); + next.get(bar(s.from)).push(s); + } + const keep = [new Set(), new Set()]; + const movesOf = (s) => + [0, 1] + .filter((i) => s.from[i] !== s.to[i]) + .map((i) => [i, [s.from[i], s.label, s.to[i]].join(SEP)]); + const price = (s) => movesOf(s).filter(([i, id]) => !keep[i].has(id)).length * nodes.length + 1; + const wanted = []; + const want = (s) => { + wanted.push(s); + for (const [i, id] of movesOf(s)) keep[i].add(id); + }; + const stepsOf = (e, said) => { + const labels = [...e.labels].map(norm); + return (labels.includes(said) ? [said] : labels).map((label) => ({ + from: split(e.from), + to: split(e.to), + label, + })); + }; + for (const c of classes) { + for (const step of edgesAlong(c)) { + const e = edges.get(step.id); + if (e) stepsOf(e, norm(step.label)).forEach(want); + } + if (diverges(c)) stepsOf(edges.get(c.second.edge), "").forEach(want); + } + const cheapest = () => { + const cost = new Map(starts.map((k) => [bar(split(k)), 0])); + const parent = new Map(); + const open = new Set(cost.keys()); + while (open.size) { + const k = [...open].reduce((a, b) => (cost.get(b) < cost.get(a) ? b : a)); + open.delete(k); + for (const s of next.get(k) ?? []) { + const c = cost.get(k) + price(s); + if (c < (cost.get(bar(s.to)) ?? Infinity)) { + cost.set(bar(s.to), c); + parent.set(bar(s.to), s); + open.add(bar(s.to)); + } + } + } + return { cost, parent }; + }; + const targets = [ + ...[0, 1].flatMap((i) => + [...beside[i].keys()].sort().map((r) => ({ at: (k) => parts.get(k)[i] === r })), + ), + ...[...new Set(steps.map((s) => s.label))] + .filter((l) => !wanted.some((s) => s.label === l)) + .sort() + .map((l) => ({ ends: (s) => s.label === l })), + ]; + while (targets.length) { + const { cost, parent } = cheapest(); + let best = null; + targets.forEach((t, n) => { + const ways = t.at + ? [...cost.keys()].filter(t.at).map((k) => ({ k, c: cost.get(k), last: null })) + : steps + .filter(t.ends) + .filter((s) => cost.has(bar(s.from))) + .map((s) => ({ k: bar(s.from), c: cost.get(bar(s.from)) + price(s), last: s })); + for (const w of ways) if (!best || w.c < best.c) best = { ...w, n }; + }); + if (!best) break; + targets.splice(best.n, 1); + const path = best.last ? [best.last] : []; + for (let s = parent.get(best.k); s; s = parent.get(bar(s.from))) path.push(s); + path.forEach(want); + } + const takes = (x, label) => + [0, 1].flatMap((i) => + [...moves[i]] + .filter(([, m]) => m.from === x[i] && m.label === label && m.beside.has(x[1 - i])) + .map(([id]) => [i, id]), + ); + let scoped; + let made; + for (;;) { + scoped = draw(keep); + made = productOf(parseMermaid(scoped.mermaid).regions); + const madeKeys = new Set(made.transitions.map((t) => key(t.from, t.label, t.to))); + const redo = [ + ...made.transitions + .filter((t) => parts.has(t.from) && !real.has(key(t.from, t.label, t.to))) + .map((t) => [parts.get(t.from), t.label]), + ...wanted + .filter((s) => made.states.has(bar(s.from))) + .filter((s) => !madeKeys.has(key(bar(s.from), s.label, bar(s.to)))) + .map((s) => [s.from, s.label]), + ]; + const before = keep[0].size + keep[1].size; + for (const [x, label] of redo) for (const [i, id] of takes(x, label)) keep[i].add(id); + if (keep[0].size + keep[1].size === before) break; + } + const sound = + [...made.states].every((s) => parts.has(s)) && + made.transitions.every((t) => real.has(key(t.from, t.label, t.to))); + if (!sound) scoped = all; + + return { + mermaid: scoped.mermaid, + states: [beside[0].size, beside[1].size], + transitions: scoped.count, + all: all.count, + flat: { states: nodes.length, transitions: steps.length }, + status: Object.fromEntries( + rank.map((s) => [ + s, + regionStatus.reduce((n, m) => n + [...m.values()].filter((x) => x === s).length, 0), + ]), + ), + groups: groupCounts(drawn.flat()), + }; +} +const regions = args.regions + ? regionsChart({ + states: allNodes, + starts: [...initial], + edges, + classes, + proofPath: args.proof, + allPath: args.regionsAll, + }) + : null; +if (regions?.why) console.error(`regions: not drawn, because ${regions.why}`); + +const flatStates = + args.pathOnly && chosen + ? onPath + : coverEdges + ? new Set([ + ...initial, + ...[...coverEdges].flatMap((id) => [edges.get(id).from, edges.get(id).to]), + ]) + : allNodes; +const sorted = [...flatStates].sort(); +const drawn = sorted.map((k) => drawnState(groupOf(k), status(k), onPath.has(k))); + +const boxes = new Map(); +const boxOf = new Map(); +const inBox = (k, box) => { + if (!boxes.has(box)) boxes.set(box, []); + boxes.get(box).push(k); +}; +if (args.view === "layered") for (const k of sorted) inBox(k, k.split(SEP)[0]); +else if (args.view === "customer" && !args.flat) { + for (const k of sorted) { + const at = k.indexOf(": "); + if (at > 0 && at + 2 < k.length) inBox(k, k.slice(0, at)); + } + for (const [box, members] of boxes) if (members.length < 2) boxes.delete(box); +} +const boxIds = new Map([...boxes.keys()].map((box, i) => [box, `g${i}`])); +for (const [box, members] of boxes) for (const k of members) boxOf.set(k, box); +const declared = [...[...boxes.values()].flat(), ...sorted.filter((k) => !boxOf.has(k))]; +const stateIds = new Map(declared.map((k, i) => [k, `s${i}`])); +const idOf = (k) => stateIds.get(k); +const caption = (k) => + args.view === "layered" ? k.split(SEP)[1] : boxOf.has(k) ? k.slice(boxOf.get(k).length + 2) : k; + +function numbered(steps) { + const runs = []; + for (const { n, shown } of steps) { + const last = runs.at(-1); + if (last?.shown === shown && last.n.at(-1) === n - 1) last.n.push(n); + else runs.push({ shown, n: [n] }); + } + return runs.map(({ shown, n }) => `${n.join(", ")}. ${shown}`); +} +const drawnLabels = new Set(); +const arrows = new Map(); +for (const [id, e] of edges) { + if (!flatStates.has(e.from) || !flatStates.has(e.to)) continue; + if (args.pathOnly && chosen && !pathNumbers.has(id) && id !== chosen.second?.edge) continue; + if (coverEdges && !coverEdges.has(id)) continue; + const pair = edgeId(e.from, e.to); + if (!arrows.has(pair)) + arrows.set(pair, { from: e.from, to: e.to, groups: [[], [], []], plain: true, labels: [] }); + const a = arrows.get(pair); + const [numbers, current, other] = a.groups; + const all = [...e.labels]; + const shown = all.map((l) => guarded(e.guards, l)); + const said = args.fullLabels ? shown.join(" or ") : joinLabels(shown); + const p = pathNumbers.get(id); + const chosenStep = chosen?.second && id === chosen.second.edge; + if (p) numbers.push(...numbered(p)); + else if (!e.secondOnly) current.push(said); + else other.push(args.pathOnly && chosenStep ? chosen.second.text : said); + if (chosenStep && !e.secondOnly) other.push(chosen.second.text); + if (p || chosenStep || id.endsWith(`${SEP}second`)) a.plain = false; + a.labels.push(...all); + for (const l of p ? p.map((s) => s.label) : all) drawnLabels.add(l); +} +const transitions = [...arrows.values()].map(({ from, to, groups, plain, labels }) => ({ + from, + to, + // ";" ends a Mermaid statement, even inside a transition label. + text: groups + .map((g) => + g + .filter(Boolean) + .map((l) => wrap(l.replaceAll(";", ","), 30)) + .join("
"), + ) + .filter(Boolean) + .join("
#160;
"), + plain, + labels: labels.sort(), +})); +const fromBox = []; +const folded = new Set(); +for (const [box, members] of boxes) { + const mine = new Set(members); + const byStep = new Map(); + for (const l of transitions) { + if (!l.plain || !mine.has(l.from) || mine.has(l.to)) continue; + const step = `${l.to}${SEP}${l.text}${SEP}${l.labels.join(SEP)}`; + if (!byStep.has(step)) byStep.set(step, []); + byStep.get(step).push(l); + } + for (const same of byStep.values()) { + if (new Set(same.map((l) => l.from)).size !== members.length) continue; + for (const l of same) folded.add(l); + fromBox.push({ ...same[0], box }); + } +} + +const out = head(drawn); +for (const [box, members] of boxes) { + out.push(` state "${quote(wrap(box))}" as ${boxIds.get(box)} {`); + for (const k of members) out.push(` state "${quote(wrap(caption(k)))}" as ${idOf(k)}`); + out.push(" }"); +} +for (const k of sorted) + if (!boxOf.has(k)) out.push(` state "${quote(wrap(caption(k)))}" as ${idOf(k)}`); +for (const k of sorted) + if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) + out.push(` [*] --> ${idOf(k)}`); +const arrow = (from, l) => ` ${from} --> ${idOf(l.to)}${l.text ? ` : ${l.text}` : ""}`; +for (const l of fromBox) out.push(arrow(boxIds.get(l.box), l)); +for (const l of transitions) if (!folded.has(l)) out.push(arrow(idOf(l.from), l)); +sorted.forEach((k, i) => out.push(` class ${idOf(k)} ${drawn[i].cls}`)); +const mermaid = regions?.mermaid ?? out.join("\n") + "\n"; +if (args.out) fs.writeFileSync(args.out, mermaid); +else process.stdout.write(mermaid); + +const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k); +const ruleOf = (c) => rules.get(c.invariant) ?? `\`${c.invariant}\``; +function secondLine(c) { + const W = cap(within); + if (c.introduced) return `Only ${L} has this bug. The steps above happen ${within} ${L}.`; + const s = c.second; + if (!s) return null; + if (s.sameEnd) + return s.ruleHolds + ? `${W} ${L} you see the same screens and steps, yet the rule holds, so the state labels do not show what ${L} changes.` + : `${W} ${L} you see the same screens and steps, so ${L} does not change this path.`; + if (s.reason) return `${cap(L)} cannot take these steps: it ${s.reason}.`; + const outcome = s.disabled + ? "cannot happen, so you stay at" + : s.key === s.from + ? "keeps you at" + : "takes you to"; + if (s.hidden) { + const when = s.step ? `after step ${s.step}` : "before your first step"; + return `${W} ${L}, ${when}, "${s.said}" happens and ${outcome}: ${show(s.key)}.`; + } + return `${W} ${L}, step ${s.step} (${s.said}) ${outcome}: ${show(s.key)}.`; +} +if (args.story) { + const lines = []; + const header = role === "fix" ? `New with ${L}: ` : `Only in ${L}: `; + classes.forEach((c, i) => { + lines.push(`## ${i + 1}. ${c.introduced ? header : ""}${show(c.end)}`, ""); + lines.push(`Rule broken: ${ruleOf(c)}`); + if (c.cause) lines.push(`Why: ${c.cause.replace(/\.$/, "")}.`); + lines.push( + c.count === 1 + ? "This is the only way to get here in the model." + : `There are ${c.count} ways to get here in the model. This is the shortest.`, + "", + ); + c.path.forEach((p, j) => + lines.push( + j === 0 ? `0. Start: ${show(p.key)}` : `${j}. ${cap(p.label)}. Now: ${show(p.key)}`, + ), + ); + lines.push(""); + const said = secondLine(c); + if (said) lines.push(said, ""); + }); + fs.writeFileSync(args.story, lines.join("\n")); +} +const CODE_LIKE = /\b[a-z]{2,}[A-Z][a-z]|\b\w+_\w+\b|\b[A-Za-z_]\w*\(|\b[A-Z][a-z]+[A-Z][a-z]/; +const codeLike = [ + ...new Set([ + ...(args.view === "customer" ? [...sorted, ...drawnLabels] : []), + ...classes.map((c) => c.cause).filter(Boolean), + ]), +].filter((t) => CODE_LIKE.test(t)); +const secondOf = (c) => + c.introduced + ? role === "fix" + ? `new with ${L}` + : `only in ${L}` + : c.second + ? c.second.sameEnd + ? c.second.ruleHolds + ? "same screens, but the rule holds" + : "unchanged" + : (c.second.reason ?? show(c.second.key)) + : null; +const summary = { + view: args.view, + ...(role ? { second: { role, label: L, lacks: lacks.map((p) => p.join(".")) } } : {}), + modelStates: graph.nodes.size, + modelTransitions: graph.edges.length, + chartStates: regions?.mermaid ? regions.states[0] + regions.states[1] : flatStates.size, + chartTransitions: regions?.mermaid ? regions.transitions : transitions.length, + chartStatus: regions?.mermaid + ? regions.status + : Object.fromEntries( + ["works", "wrong", "fixed"].map((s) => [ + s, + [...flatStates].filter((k) => status(k) === s).length, + ]), + ), + ...(grouping + ? { + chartGroups: regions?.mermaid ? regions.groups : groupCounts(drawn), + mixedGroups: mixedGroups.map(show).sort(), + } + : {}), + ...(regions + ? { + regions: regions.mermaid + ? { + drawn: true, + states: regions.states, + transitions: regions.transitions, + all: regions.all, + flat: regions.flat, + } + : { drawn: false, why: regions.why }, + } + : {}), + classes: classes.map((c, i) => ({ + n: i + 1, + end: show(c.end), + invariant: c.invariant, + rule: rules.get(c.invariant) ?? null, + cause: c.cause, + readerSteps: c.path.length - 1, + modelSteps: c.trace.steps.length, + counterexamples: c.count, + [role ?? "fix"]: secondOf(c), + })), + codeLike, +}; +if (args.summary) fs.writeFileSync(args.summary, JSON.stringify(summary, null, 2) + "\n"); +const extraBugs = introduced.length + ? `, ${introduced.length} ${role === "fix" ? `new with ${L}` : `only in ${L}`}` + : ""; +const drawnAs = regions?.mermaid + ? ` of ${regions.all} in 2 regions (proved against ${regions.flat.states} states, ${regions.flat.transitions} transitions)` + : ""; +console.error( + `chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions${drawnAs} from ${summary.modelStates} model states; ` + + `${mainClasses.length} bug(s)${extraBugs}`, +); diff --git a/plugins/pstack/skills/swarm/SKILL.md b/plugins/pstack/skills/swarm/SKILL.md index c78fe80..fd2b4ea 100644 --- a/plugins/pstack/skills/swarm/SKILL.md +++ b/plugins/pstack/skills/swarm/SKILL.md @@ -6,8 +6,6 @@ disable-model-invocation: true # Swarm -Fan out N parallel workers. They may cover separate slices, race the same brief, or mix both. The parent waits, aggregates, and returns one report. - ## Start Open a todolist with one entry per phase before launching anything. @@ -21,7 +19,6 @@ Open a todolist with one entry per phase before launching anything. 1. State the done predicate and the artifact or report the swarm must return. 2. Choose the shape. Partition into slices, race N workers on identical briefs, or mix both. For a race or mixed shape, declare `first pass`, `rank all`, or `best-of` before spawning. -3. Set N from the user or derive it from the shape. N is total workers. `task.maxConcurrency` in `~/.omp/agent/config.yml` caps how many run at once. 4. Pick exact discovered agent names. Inspect each agent's frontmatter or `task.agentModelOverrides[]` for its model. A model race needs one loaded agent file and one exact override entry per arm; overrides cannot create agents. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. 5. Give each worker its own writable output when it writes. When workers verify or measure commits, each brief names the exact SHAs. A measurement brief also names the method (sample count, what one sample is, order). The worker records both in its result. @@ -39,8 +36,6 @@ If a worker drops out, proceed with N-1 and note it. Read the terminal results. Drop a result that does not record the SHAs and method its brief names, and respawn that worker once. After a second miss, record a gap. A gap does not count as a pass. For coverage, every required slice needs a result. For a race, apply the selection rule declared up front. Use first pass, rank all, or best-of. Do not paste raw worker dumps. -Keep a compact result table, one-line evidenced issues, and explicit gaps or dropouts. - ## Phase D: Report Return one consolidated in-chat report with the table, issue one-liners, gaps or dropouts, and the race rule when used. diff --git a/plugins/pstack/skills/typescript-best-practices/SKILL.md b/plugins/pstack/skills/typescript-best-practices/SKILL.md index ac442f8..c94d9e9 100644 --- a/plugins/pstack/skills/typescript-best-practices/SKILL.md +++ b/plugins/pstack/skills/typescript-best-practices/SKILL.md @@ -12,7 +12,7 @@ Apply the **type-system-discipline** principle skill first. | Rule | Summary | |------|---------| | Discriminated unions | Model variants with a `kind` literal discriminant so impossible states can't be represented. No optional-field bags. | -| Branded types | Brand primitives with `& { readonly __brand: "X" }` so they can't be mixed up. Validate once at the boundary. | +| Branded types | Brand primitives with `& { readonly __brand: "X" }` so they can't be mixed up. Validate once at the boundary. A brand comes from a verifying type guard (`input is AgentId`), never from `as`. | | Constructive modeling | Build the shape so the illegal value can't be constructed. `[T, ...T[]]` for non-empty, `[T, T][]` for even length, `start` plus `duration` for a range. Not a runtime guard, not a wish for refinement types. | | Simplest total type | Keep `T[]` while every operation on it stays total. Strengthen to `NonEmpty` only where the loose type forces `!`, a cast, or a "should never happen" throw. | | `unknown` over `any` | External data is `unknown`. | diff --git a/plugins/pstack/skills/typescript-best-practices/references/patterns.md b/plugins/pstack/skills/typescript-best-practices/references/patterns.md index c21d876..f8dd99b 100644 --- a/plugins/pstack/skills/typescript-best-practices/references/patterns.md +++ b/plugins/pstack/skills/typescript-best-practices/references/patterns.md @@ -9,9 +9,13 @@ Brand primitives so they can't be mixed up. Validate once at the boundary. Downs ```ts type AgentId = string & { readonly __brand: "AgentId" }; +function isAgentId(input: string): input is AgentId { + return isUUID(input); +} + function parseAgentId(input: string): AgentId { - if (!isUUID(input)) throw new Error(`Invalid agent id: ${input}`); - return input as AgentId; + if (!isAgentId(input)) throw new Error(`Invalid agent id: ${input}`); + return input; } function focusAgent(id: AgentId): void { From ef7a1fccc3d4cee5ec1663b865d27572d0745732 Mon Sep 17 00:00:00 2001 From: "mj.lee" Date: Fri, 23 Oct 2026 00:43:38 +0900 Subject: [PATCH 03/10] fix(port): stop carrying the Agent Plugins manifest With plugin.json at the plugin root, omp's agent-plugins provider claims the package. That provider checks skill frontmatter against a closed field set and skips every skill that has disable-model-invocation, icon, color, or globs. On omp 18.12.2 it skips 54 of the 56 skills, so skill://poteto-mode and /skill:how are unknown. The build no longer extracts pstack/plugin.json, and the plugin manifest check now fails when the file is present. --- PORTING.md | 6 ++++++ omp-port/check-port.sh | 23 ++++++----------------- omp-port/lib.sh | 7 +------ omp-port/sync-upstream.sh | 3 --- plugins/pstack/plugin.json | 21 --------------------- 5 files changed, 13 insertions(+), 47 deletions(-) delete mode 100644 plugins/pstack/plugin.json diff --git a/PORTING.md b/PORTING.md index c1098a7..85b9ae0 100644 --- a/PORTING.md +++ b/PORTING.md @@ -132,6 +132,12 @@ Everything below `pstack/skills` and `pstack/agents` is outside this port. The b `.cursor-plugin/plugin.json` is not vendored and never was in the current build: it sits outside the two subtrees `omp-port/lib.sh` archives, and omp never read it. Nothing in the tree parses it. +`pstack/plugin.json`, upstream's Agent Plugins manifest, is not carried either. At the plugin root it +makes omp's `agent-plugins` provider claim the package, and that provider checks skill frontmatter +against a closed field set. It skipped every skill with `disable-model-invocation`, `icon`, `color`, +or `globs`, 54 of 56 at 0.15.25 on omp 18.12.2. Without it, the `omp-plugins` provider reads the +plugin root's `skills/` directory and loads all of them. The `plugin manifest` gate asserts the absence. + ## Install, the two live shapes `omp plugin link /plugins/pstack` is the maintainer shape. It registers the checkout as an diff --git a/omp-port/check-port.sh b/omp-port/check-port.sh index 720a05c..7039d8f 100755 --- a/omp-port/check-port.sh +++ b/omp-port/check-port.sh @@ -522,25 +522,14 @@ else report "owned" "PASS $ownn owned path(s) present" fi -# Upstream ships an Agent Plugins manifest so the plugin loads in any harness. The port copies it -# through rather than rewriting it, because it is pure metadata: it names the plugin and says -# nothing about how a task is spawned. Its absence is not visible anywhere else, so it is asserted -# here. A manifest whose version disagrees with the pin means one of the two was written by hand. -mfbad="" -if [ ! -f plugin.json ]; then - mfbad="plugins/pstack/plugin.json missing; upstream's Agent Plugins manifest is not carried through" -elif ! jq -e . >/dev/null 2>&1 Date: Thu, 22 Oct 2026 23:29:10 +0300 Subject: [PATCH 04/10] sync: build upstream pstack 4ec7ffa (0.15.30) --- .omp-plugin/marketplace.json | 2 +- omp-port/UPSTREAM | 2 +- plugins/pstack/skills/statechart/SKILL.md | 8 +- .../pstack/skills/statechart/scripts/lib.mjs | 245 ++++++- .../skills/statechart/scripts/statechart.mjs | 382 ++++++++--- .../scripts/test/statechart.test.mjs | 603 ++++++++++++++++-- .../statechart/scripts/tlc-to-chart.mjs | 201 ++++-- 7 files changed, 1221 insertions(+), 222 deletions(-) diff --git a/.omp-plugin/marketplace.json b/.omp-plugin/marketplace.json index f6db0f2..11b5ffd 100644 --- a/.omp-plugin/marketplace.json +++ b/.omp-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "pstack", - "description": "59 skills, 23 playbooks, 24 principles, the poteto-mode pin, and two agents. Upstream cursor/plugins pstack at eb4db2b.", + "description": "59 skills, 23 playbooks, 24 principles, the poteto-mode pin, and two agents. Upstream cursor/plugins pstack at 4ec7ffa.", "version": "0.15.30", "category": "development", "license": "MIT", diff --git a/omp-port/UPSTREAM b/omp-port/UPSTREAM index a5d6977..eebdeec 100644 --- a/omp-port/UPSTREAM +++ b/omp-port/UPSTREAM @@ -1 +1 @@ -eb4db2b55b2b7dec3d9282754cddf0a56bf4ff10 +4ec7ffa173eaa70d3d9467a627baf0954645a353 diff --git a/plugins/pstack/skills/statechart/SKILL.md b/plugins/pstack/skills/statechart/SKILL.md index 3cbeca9..08724ea 100644 --- a/plugins/pstack/skills/statechart/SKILL.md +++ b/plugins/pstack/skills/statechart/SKILL.md @@ -14,16 +14,16 @@ Run `node /scripts/statechart.mjs --help` for the commands and for ho Use `from-spec` when a TLA+ model exists or the code is concrete enough to model. Use `from-chart` when there is no model and the PM should agree on the behavior before anyone writes or changes the code. -If the code already has a TLA+ spec, start from a copy of that spec. Add the variables the user sees, and keep the spec's existing invariants. To audit the code against that spec, make the copy model what the code does, and pass the spec unchanged as `--against`. In a new spec, tie every variable and action to code that a reviewer can open, and take its name from that code. Make each action one indivisible step in the code. +If the code already has a TLA+ spec, start from a copy of that spec. Add the variables the user sees, and keep the spec's existing invariants. To audit the code against that spec, make the copy model what the code does, and pass the spec unchanged as `--against`. Check it with the config that its authors say it passes, and pass that config as `--against-config` unless it has the spec's file name. One mapping labels both models, so label states and steps only from variables that both have. In a code model or a new spec, tie every variable and action to code that a reviewer can open, and take its name from that code. Make each action one indivisible step in the code. Put the file and line of the code that checks each condition of an action in a comment on that condition, as in `\* pay.ts:120`. ## Spec to chart 1. Choose the chart's states. When the PM cares about something the screen does not show, such as a second charge while the screen still says "Paying", name the state "Paying, charged twice". Start with few states, and split a state only when the split separates two bugs. -2. Write each rule the PM agrees to as one invariant, with the PM's sentence as its comment. For a rule that applies once everything settles, define `Settled == ~ENABLED System`, where `System` is the disjunction of the actions the user does not take. Report each broken rule as a bug. Never weaken a rule to make the chart pass. +2. Write each rule the PM agrees to as one invariant, with the PM's sentence as its comment. Each rule names what the user sees on a screen or gets from a request, and the code that can change it. Refuse a rule that lacks one of them. For a rule that applies once everything settles, define `Settled == ~ENABLED System`, where `System` is the disjunction of the actions the user does not take. Report each broken rule as a bug. Never weaken a rule to make the chart pass. 3. Run `from-spec`, then fill in the mapping file it writes. Put the labels in `state` and `step`, and list the user's actions in `readerActions` by action name. When two bugs end on the same screen through different code paths, add `cause` and make it name the code path. To color the states by what the PM cares about, such as "Toast shown", return that group from `group`. The legend names the groups, and states that break a rule still get a red border. 4. If there is an open fix, model it from the code and pass that model as `--fixed`. 5. Render each chart with `--png` and look at every PNG. Check that each numbered path reads in order. If a label reads like code, fix `state` or `step` and run the command again. -6. Replay each bug's story against the real code, as a test or a live repro, one step per model action. If a bug does not reproduce, assume the model is wrong until you show otherwise. Fix the model, not the rule. +6. Replay each bug's story against the real code, as a test or a live repro, one step per model action. Drive only entry points that a user or client reaches, and assert only what they see. A test that asserts a flag that it sets, or that searches the source text, proves nothing. When a run proves part of the story and reading the code proves the rest, say which part each proves. When the test copies code that it cannot import, give the commit and line range of the copy, and list what the copy leaves out. If a bug does not reproduce, assume the model is wrong until you show otherwise. Fix the model, not the rule. ## Chart to spec @@ -33,4 +33,4 @@ If the code already has a TLA+ spec, start from a copy of that spec. Add the var ## Hand off -Give the PM the PNG and the Mermaid source of each chart. Add one line per bug with the rule it breaks, its steps as the chart numbers them, whether it reproduced, and whether the open fix fixes it. Take every count you quote from `summary.json`. Attach `stories.md` and the command that regenerates everything. Say what the model leaves out. +Give the PM the PNG and the Mermaid source of each chart. Add one line per bug with the rule it breaks, its steps as the chart numbers them, whether it reproduced, and whether the open fix fixes it. With `--against`, also say whether the spec already lists it: in the configs that `stories.md` names, in the spec's notes on what the model leaves out, or not at all. Do not report that a rule holds while the command lists conditions with no file and line. Take every count you quote from `summary.json`. Attach `stories.md` and the command that regenerates everything. Say what the model leaves out. diff --git a/plugins/pstack/skills/statechart/scripts/lib.mjs b/plugins/pstack/skills/statechart/scripts/lib.mjs index cf72d5c..49f1675 100644 --- a/plugins/pstack/skills/statechart/scripts/lib.mjs +++ b/plugins/pstack/skills/statechart/scripts/lib.mjs @@ -151,6 +151,183 @@ export function ruleSentences(tla) { return out; } +export const tlaVariables = (code) => + [...code.matchAll(/\bVARIABLES?\s+(\w+(?:\s*,\s*\w+)*)/g)].flatMap((m) => m[1].split(/\s*,\s*/)); + +function splitCommentsKeepingOffsets(text) { + let code = ""; + let notes = ""; + let block = 0; + let line = false; + let string = false; + const put = (c, n) => { + code += c; + notes += n; + }; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + const two = text.slice(i, i + 2); + if (c === "\n") { + line = string = false; + put(c, c); + } else if (block) { + if (two === "(*" || two === "*)") { + block += two === "(*" ? 1 : -1; + put(" ", two); + i++; + } else put(" ", c); + } else if (line) put(" ", c); + else if (string) { + if (c === "\\" && text[i + 1] && text[i + 1] !== "\n") { + put(two, " "); + i++; + } else { + string = c !== '"'; + put(c, " "); + } + } else if (two === "\\*" || two === "(*") { + if (two === "\\*") line = true; + else block = 1; + put(" ", two); + i++; + } else { + string = c === '"'; + put(c, " "); + } + } + return { code, notes }; +} + +const IDENTIFIER = /(?)/g; +const CITATION = /[\w./-]+\.[A-Za-z]\w*:\d+|#L\d+/; + +const DEPTH = { "(": 1, "[": 1, "{": 1, ")": -1, "]": -1, "}": -1 }; +const LOOSER_THAN_AND = /^(\\\/|=>|<=>|~>|-\+->|\\equiv\b)/; +const RUNS_TO_END = /^(\\[EA]|IF|LET|CASE|CHOOSE)\b/; +export const PRIMES_OR_UNCHANGED = /[\w)\]>}]'|\bUNCHANGED\b/; + +function conjuncts(bare, from, to, lineStart) { + const first = from + bare.slice(from, to).search(/\S/); + if (first < from) return null; + if (/^\\E\b/.test(bare.slice(first, to))) { + for (let i = first, depth = 0; i < to; i++) { + depth += DEPTH[bare[i]] ?? 0; + if (depth === 0 && bare[i] === ":") return conjuncts(bare, i + 1, to, lineStart); + } + return null; + } + if (bare.startsWith("/\\", first)) { + const column = first - lineStart(first); + const bullets = [first]; + for (const m of bare.slice(first, to).matchAll(/\n([ \t]*)(?=\S)/g)) { + if (m[1].length < column) return null; + const at = first + m.index + m[0].length; + if (m[1].length === column && bare.startsWith("/\\", at)) bullets.push(at); + } + return bullets.flatMap((b, i) => { + const end = bullets[i + 1] ?? to; + return conjuncts(bare, b + 2, end, lineStart) ?? [[b + 2, end]]; + }); + } + const cuts = []; + let depth = 0; + for (let i = first; i < to; i++) { + const two = bare.slice(i, i + 2); + const ahead = bare.slice(i, i + 7); + if (two === "<<" || two === ">>") { + depth += two === "<<" ? 1 : -1; + i++; + } else if (DEPTH[bare[i]]) depth += DEPTH[bare[i]]; + else if (depth > 0) continue; + else if (LOOSER_THAN_AND.test(ahead)) return null; + else if (!/\w/.test(bare[i - 1] ?? "") && RUNS_TO_END.test(ahead)) break; + else if (two === "/\\") cuts.push(i++); + } + return [first, ...cuts.map((c) => c + 2)].map((a, i) => [a, cuts[i] ?? to]); +} + +export function uncitedConditions(text) { + const { code, notes } = splitCommentsKeepingOffsets(text); + const bare = code.replace(/"(?:[^"\\\n]|\\.)*"/g, (s) => " ".repeat(s.length)); + const variables = new Set(tlaVariables(bare)); + const starts = [0, ...[...bare.matchAll(/\n/g)].map((m) => m.index + 1)]; + const lineOf = (at) => { + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid] <= at) lo = mid; + else hi = mid - 1; + } + return lo; + }; + const lineStart = (at) => starts[lineOf(at)]; + const lines = { bare: bare.split("\n"), notes: notes.split("\n") }; + + const defs = new Map(); + const nextItem = /^(?![ \t\n]|\/\\|\\\/)/gm; + for (const m of bare.matchAll( + /^(?:LOCAL[ \t]+)?([A-Za-z_]\w*)[ \t]*(?:\([^)]*\)|\[[^\]]*\])?[ \t]*==/gm, + )) { + const from = m.index + m[0].length; + nextItem.lastIndex = from; + defs.set(m[1], { from, to: nextItem.exec(bare)?.index ?? bare.length, line: lineOf(m.index) }); + } + const direct = (body) => { + const ids = body.match(IDENTIFIER) ?? []; + return { + reads: ids.filter((id) => variables.has(id)), + writes: [...body.matchAll(/(? m[1]) + .filter((id) => variables.has(id)), + acts: PRIMES_OR_UNCHANGED.test(body), + calls: [...new Set(ids.filter((id) => defs.has(id)))], + }; + }; + const own = new Map([...defs].map(([name, d]) => [name, direct(bare.slice(d.from, d.to))])); + const through = (start) => { + const out = { reads: new Set(start.reads), writes: new Set(start.writes), acts: start.acts }; + const seen = new Set(start.calls); + const todo = [...seen]; + while (todo.length) { + const d = own.get(todo.pop()); + d.reads.forEach((v) => out.reads.add(v)); + d.writes.forEach((v) => out.writes.add(v)); + out.acts ||= d.acts; + for (const c of d.calls.filter((c) => !seen.has(c))) { + seen.add(c); + todo.push(c); + } + } + return out; + }; + const cited = (from, to, { above }) => { + for (let l = from; l <= to; l++) if (CITATION.test(lines.notes[l])) return true; + for (let l = from - 1; above && !lines.bare[l].trim() && lines.notes[l].trim(); l--) + if (CITATION.test(lines.notes[l])) return true; + return false; + }; + + const found = []; + for (const [action, d] of defs) { + if (!own.get(action).acts) continue; + const changes = through(own.get(action)).writes; + for (const [a, b] of conjuncts(bare, d.from, d.to, lineStart) ?? []) { + const part = through(direct(bare.slice(a, b))); + if (part.acts || [...part.reads].every((v) => changes.has(v))) continue; + const first = lineOf(a + bare.slice(a, b).search(/\S/)); + const last = lineOf(a + bare.slice(a, b).trimEnd().length - 1); + if (cited(first, last, { above: first > d.line })) continue; + found.push({ + line: first + 1, + action, + condition: code.slice(a, b).replace(/\s+/g, " ").trim(), + }); + } + } + return found; +} + function unescapeDot(s) { let out = ""; for (let i = 0; i < s.length; i++) { @@ -239,24 +416,52 @@ export function parseTraces(text) { return traces; } +// TLC words a broken PROPERTY by its form: [](state predicate) as an invariant, [][A]_v as an +// action property, a bare state predicate, which TLC checks only in the initial states, as a +// property of the initial state, and any other formula as a temporal property. +export const BROKEN_PROPERTY = + /^Error: (Invariant \S+ is violated|Action property \S+ is violated\.|Property \S+ is violated by the initial state:|Temporal properties were violated\.)/m; + // With -continue, TLC prints "Model checking completed" even after broken invariants, and not -// when it stopped early. -export function checkTlcOutput(text, file) { - const other = text - .split("\n") - .find( - (l) => - l.startsWith("Error: ") && - !/^Error: (Invariant \S+ is violated|The behavior up to this point is)/.test(l), - ); +// when it stopped early. TLC can also throw a Java exception of its own while it reports a state +// that breaks a rule, and stop early. An error in the rule's formula throws one of TLC's own +// exceptions in place of java.*. +export function tlcOutputWarning(text, file) { + const lines = text.split("\n"); + const named = (re) => new Set(lines.map((l) => re.exec(l)?.[1]).filter(Boolean)); + const broken = named(/^Error: Invariant (\S+) is violated/); + const failed = [...named(/^Error: Evaluating invariant (\S+) failed\./)]; + const thrown = [...text.matchAll(/^The exception was a (\S+)\n: (?:([\w.$]+):)?(.*)$/gm)].map( + ([, cls, cause, message]) => `${cause ?? cls}: ${message.trim()}`, + ); + const crashed = + failed.length > 0 && + failed.every((r) => broken.has(r)) && + thrown.length > 0 && + thrown.every((t) => t.startsWith("java.")); + const reported = crashed + ? /^Error: (Invariant \S+ is violated|The behavior up to this point is|Evaluating invariant \S+ failed\.|TLC threw an unexpected exception\.|The error occurred when TLC was evaluating the nested)/ + : /^Error: (Invariant \S+ is violated|Action property \S+ is violated\.|Property \S+ is violated by the initial state:|The behavior up to this point is|Temporal properties were violated\.|The following behavior constitutes a counter-example:)/; + const other = lines.find((l) => l.startsWith("Error: ") && !reported.test(l)); if (other) throw new Error( `${file}: TLC stopped on an error that is not a broken rule: ${other.slice(7)}`, ); - if (!/^Model checking completed\./m.test(text)) + if (crashed) { + const rules = failed.join(", "); + return `TLC found that ${rules} ${failed.length === 1 ? "is" : "are"} broken, and then failed with ${thrown[0]}. TLC stopped before it checked every state, so the charts can miss states that break ${rules}. The failure is in TLC, not in the rule. Do not weaken ${rules} because of it. A smaller constant can let TLC finish.`; + } + // TLC stops at the first broken temporal property, and at an initial state that breaks a + // PROPERTY, and then does not print that it completed. + if ( + !/^(Model checking completed\.|Error: (Temporal properties were violated\.|Property \S+ is violated by the initial state:))/m.test( + text, + ) + ) throw new Error( `${file}: TLC did not finish, so its counterexamples are not all there; read the file`, ); + return null; } export function wrap(text, width = 26) { @@ -273,6 +478,9 @@ export function wrap(text, width = 26) { return rows.join("
"); } +export const joinAnd = (items) => + items.length < 3 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`; + const OPENS_A_PHRASE = new Set( "a an the your my our their its this that to of in on at for with from by and or".split(" "), ); @@ -296,8 +504,7 @@ const unwrap = (s) => .replace(/\s+/g, " ") .trim(); -// Mermaid ends a quoted state caption at the first ", so a caption writes " as #quot;. -export const quote = (s) => String(s).replaceAll('"', "#quot;"); +export const escapeMermaidQuotes = (s) => String(s).replaceAll('"', "#quot;"); export const LEGEND_ID = "legend"; export const FULL_LABELS = "%% labels in full"; @@ -410,15 +617,14 @@ export function parseMermaid(text) { "the chart has -- outside a composite; to make parallel regions, split one top-level composite with --", ); - // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. function region(p) { const out = { states: new Set(), initial: new Set(), transitions: [] }; const info = new Map(); - const declared = new Set(); + const declaredInRegion = new Set(); (function collect(q) { for (const c of q.children) { if (c.name === LEGEND_ID) continue; - declared.add(c.name); + declaredInRegion.add(c.name); if (!c.leaf) for (const sub of c.parts) collect(sub); } })(p); @@ -432,8 +638,8 @@ export function parseMermaid(text) { const local = q.children.filter((c) => c.name !== LEGEND_ID); for (const t of q.transitions) { for (const end of [t.from, t.to]) { - if (end === "[*]" || declared.has(end)) continue; - declared.add(end); + if (end === "[*]" || declaredInRegion.has(end)) continue; + declaredInRegion.add(end); local.push({ name: end, leaf: true }); } } @@ -687,8 +893,7 @@ const rgb = (hex) => { const h = hex.length === 4 ? [...hex.slice(1)].map((c) => c + c).join("") : hex.slice(1); return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); }; -// WCAG relative luminance: 0 for black, 1 for white. -function luminance(hex) { +function wcagRelativeLuminance(hex) { const [r, g, b] = rgb(hex).map((c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; @@ -698,7 +903,7 @@ function luminance(hex) { export function groupColorProblem(color) { if (typeof color !== "string" || !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) return 'is not "#rgb" or "#rrggbb"'; - if (luminance(color) < 0.5) return "is too dark for the status borders to show on it"; + if (wcagRelativeLuminance(color) < 0.5) return "is too dark for the status borders to show on it"; return null; } diff --git a/plugins/pstack/skills/statechart/scripts/statechart.mjs b/plugins/pstack/skills/statechart/scripts/statechart.mjs index 6e2ebfc..0ff4255 100644 --- a/plugins/pstack/skills/statechart/scripts/statechart.mjs +++ b/plugins/pstack/skills/statechart/scripts/statechart.mjs @@ -5,13 +5,22 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { checkTlcOutput, findTlc, parseDot } from "./lib.mjs"; +import { + BROKEN_PROPERTY, + findTlc, + joinAnd, + parseDot, + PRIMES_OR_UNCHANGED, + tlaVariables, + tlcOutputWarning, + uncitedConditions, +} from "./lib.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const USAGE = { "from-spec": - "node statechart.mjs from-spec SPEC.tla [--fixed FIXED.tla | --against AGREED.tla] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]", + "node statechart.mjs from-spec SPEC.tla [--config FILE] [--fixed FIXED.tla [--fixed-config FILE] | --against AGREED.tla [--against-config FILE]] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]", "from-chart": "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]", }; @@ -21,27 +30,48 @@ const FROM_SPEC = `from-spec SPEC.tla Checks each INVARIANT in SPEC.cfg in a separate TLC run. TLC stops at the first rule that a state breaks, and the separate runs find the rest. Each run also has the rule as a CONSTRAINT, so TLC stops each path at its first state that breaks the rule, and a rule that stays broken reports - only the step that broke it. SPEC.cfg sits next to the spec and has SPECIFICATION and one - INVARIANT line per rule. One more run dumps the state graph, and then the command draws the - charts. TLC names each step after the innermost named action that takes it, so give each kind of - step its own named action. The command warns when steps carry the name of the whole next-state - action. - + only the step that broke it. SPEC.cfg has SPECIFICATION and one INVARIANT line per rule. Each + PROPERTY, a rule about whole behaviors, gets one TLC run too, and the charts do not draw it. A + rule listed as both INVARIANT and PROPERTY is checked once, as an INVARIANT. One + more run dumps the state graph, and then the command draws the charts. TLC names each step after + the innermost named action that takes it, so give each kind of step its own named action. The + command warns when steps carry the name of the whole next-state action. The command prints the + config that it checks each model with, and stops when a config does not exist, with the configs + beside the spec. + + --config FILE The config for SPEC.tla. Default: SPEC.cfg next to the spec. --fixed FIXED.tla The model of the open fix. It is a copy of the spec with its own file and module name, such as SpecFixed.tla with MODULE SpecFixed. The command checks - it the same way, with FIXED.cfg, or with SPEC.cfg when there is no FIXED.cfg. - The charts then mark each bad state fixed or still wrong. + it the same way, with FIXED.cfg, or with the config of SPEC.tla when there is + no FIXED.cfg. The charts then mark each bad state fixed or still wrong. + --fixed-config FILE + The config for FIXED.tla. --against AGREED.tla The spec that SPEC.tla must follow, when SPEC.tla models the code. It needs - its own file and module name. The command checks it with AGREED.cfg, or with - SPEC.cfg when there is no AGREED.cfg. SPEC.tla may add variables, record - fields, and actions that AGREED.tla does not have. The comparison leaves them - out. Do not use it with --fixed. + its own file and module name. The command checks it with AGREED.cfg, and + never with the config of SPEC.tla. SPEC.tla may add variables, record fields, + and actions that AGREED.tla does not have. The comparison leaves them out. + Do not use it with --fixed. + --against-config FILE + The config for AGREED.tla, when it is not AGREED.cfg. Give the config that the + spec's authors say the unchanged spec passes. A rule that this config does not + check is "not checked" in the stories. --label TEXT The name of the --fixed or --against model in the charts and the stories. Default: "the fix", or "the spec". --map FILE The labels for each model state and step. Default: SPEC.map.mjs next to the - spec. When the file does not exist, the command writes one to fill in and - exits 2. Its comments say what each function gets.`; + spec. When the file does not exist, the command writes one from the spec's + VARIABLES and actions to fill in, and exits 2 before it runs TLC. Its + comments say what each function gets. + + With --fixed or --against, SPEC.tla (and FIXED.tla) models the code, and the command finds each + condition of an action that no comment ties to code with a file and line, as in \\* pay.ts:120. + A condition is a conjunct of the action that changes nothing and reads a variable that the + action does not change. A rule can hold only because the model waits for a condition that the + code does not check, so the command prints these conditions when a rule holds. + With --against, each story says whether a config beside AGREED.tla expects TLC to break the + bug's rule. A config's "\\* expect: RULE" line says what TLC finds, and "\\* module: NAME" names + the module it checks when the folder has more than one .tla file. The command warns when no + config beside AGREED.tla has an expect line for that module.`; const FROM_CHART = `from-chart CHART.mmd Writes NAME.tla, NAME.cfg, and NAME.map.mjs next to the chart. When NAME.tla already exists, it @@ -63,15 +93,16 @@ const FROM_CHART = `from-chart CHART.mmd const COMMON = `Both commands --expect-broken RULE - A rule that is known to break. Repeat it, or list rules with commas, as in - A,B. The command then exits 0 when exactly these rules break, and the --fixed - or --against model breaks no other rule. + An INVARIANT or PROPERTY that is known to break. Repeat it, or list rules + with commas, as in A,B. The command then exits 0 when exactly these rules + break, and the --fixed or --against model breaks no other rule. --title TEXT The chart title. Each bug chart adds ", bug N". --out DIR The output folder. Default: NAME-statechart next to the input. A run writes its files in a temporary folder, and then replaces the whole output folder with it. When runs overlap, the output folder has the files of the run that finished last. The command does not use an output folder that has files that - it did not write. + it did not write. On a filesystem that cannot rename, the command copies the + files into place, and overlapping runs can mix their files. --png Also render each chart to PNG with npx @mermaid-js/mermaid-cli. -h, --help Print this help, and exit 0. @@ -87,7 +118,10 @@ Output detail(). stories.md One entry per bug: the rule, why it breaks, the shortest path in reader steps, and what the --fixed or --against model does instead. - summary.json The counts behind the chart. + summary.json The counts behind the chart. With --fixed or --against, uncited has the + conditions with no file and line for each code model. With --against, + second.listed has what each config beside AGREED.tla expects, and each bug + has the configs that list it. tlc/ The config and the TLC output of each run. When the overview has regions, from-spec also writes tlc/regions-all.mmd, the regions with every arrow, and tlc/regions-proof.mmd, every state and step of TLC's graph in one region. @@ -137,6 +171,13 @@ TLC TLA2TOOLS is the path to tla2tools.jar, and JAVA is the java binary (default: java, Java 11 or later). Download tla2tools.jar from https://github.com/tlaplus/tlaplus/releases. When TLA2TOOLS is not set, the command says where to get TLC and exits 2. + TLC checks each rule with one worker per CPU. When a rule is broken, TLC checks it again with one + worker, which finds the shortest bug paths, the same on every run. The command then prints how + many times TLC ran and how long that took. + TLC can find that a rule is broken and then fail with a Java exception while it reports the rule. + The command then keeps the paths that TLC printed, marks the rule broken, and warns that the + charts can miss states that break it. The failure is in TLC, so do not weaken the rule because of + it. A smaller constant can let TLC finish. Exit codes 0 Every rule holds, or exactly the --expect-broken rules break. from-chart also draws CHART.mmd @@ -150,7 +191,9 @@ Exit codes files it wrote and exits 2, whatever the rules give. Otherwise, exit 2 does not change the output folder, unless the command moved it aside and cannot put it back. Then the message names the folder's new path. When TLC stops on an error, the message names TLC's output, in a - temporary folder that the command keeps.`; + temporary folder that the command keeps. When the command cannot put its files in the output + folder, it keeps them in a temporary folder, and the message names it. The command checks + its input files again before --png renders, so a changed input stops the run before that.`; const HELP = { "": `${usage([...Object.values(USAGE), "node statechart.mjs [from-spec|from-chart] --help"])} @@ -172,10 +215,22 @@ const more = (cmd) => class CannotFinish extends Error {} class TlcStopped extends CannotFinish {} +class NotPublished extends CannotFinish {} const COMMANDS = { "from-spec": { - flags: ["--map", "--fixed", "--against", "--label", "--expect-broken", "--title", "--out"], + flags: [ + "--map", + "--config", + "--fixed", + "--fixed-config", + "--against", + "--against-config", + "--label", + "--expect-broken", + "--title", + "--out", + ], run: fromSpec, }, "from-chart": { flags: ["--module", "--expect-broken", "--title", "--out"], run: fromChart }, @@ -203,7 +258,10 @@ function parseArgs([command, ...rest]) { return { command, input, flags }; } -const rel = (file) => path.relative(process.cwd(), file) || "."; +const rel = (file) => { + const r = path.relative(process.cwd(), file) || "."; + return r === ".." || r.startsWith(`..${path.sep}`) ? path.resolve(file) : r; +}; const cfgOf = (spec) => spec.replace(/\.tla$/, ".cfg"); function findTools() { @@ -235,16 +293,28 @@ function splitRules(cfg) { .filter(Boolean); const kept = []; const rules = []; + const properties = []; let section = ""; for (const w of words) { if (KEYWORD.test(w)) section = w; - if (!/^INVARIANTS?$/.test(section)) kept.push(KEYWORD.test(w) && kept.length ? `\n${w}` : w); - else if (w !== section && !rules.includes(w)) rules.push(w); + const list = /^INVARIANTS?$/.test(section) + ? rules + : /^PROPERT(Y|IES)$/.test(section) + ? properties + : null; + if (!list) kept.push(KEYWORD.test(w) && kept.length ? `\n${w}` : w); + else if (w !== section && !list.includes(w)) list.push(w); } - return { cfgWithoutRules: `${kept.join(" ")}\n`, rules }; + // TLC takes only a state predicate as an INVARIANT, and checks a state predicate given as a + // PROPERTY only in the initial states, which the INVARIANT run checks too. + return { + cfgWithoutRules: `${kept.join(" ")}\n`, + rules, + properties: properties.filter((p) => !rules.includes(p)), + }; } -function tlc(tools, spec, cfg, base, dump) { +function tlc(tools, spec, cfg, base, { dump, workers = "1" } = {}) { fs.writeFileSync(`${base}.cfg`, cfg); const metadir = fs.mkdtempSync(path.join(os.tmpdir(), "tlc-")); // TLC writes the standard modules, such as Naturals.tla, to java.io.tmpdir under fixed names, @@ -255,10 +325,13 @@ function tlc(tools, spec, cfg, base, dump) { const r = spawnSync( tools.java, [ + "-XX:+UseParallelGC", `-Djava.io.tmpdir=${javaTmp}`, "-cp", tools.jar, "tlc2.TLC", + "-workers", + workers, "-metadir", metadir, "-deadlock", @@ -274,18 +347,20 @@ function tlc(tools, spec, cfg, base, dump) { if (r.error) throw new CannotFinish(`cannot run TLC: ${r.error.message}`); fs.writeFileSync(`${base}.out`, r.stdout); try { - checkTlcOutput(r.stdout, `${base}.out`); + return { stdout: r.stdout, warning: tlcOutputWarning(r.stdout, `${base}.out`) }; } catch (e) { throw new TlcStopped(e.message); } - return r.stdout; } -function warnUnnamedSteps(spec, cfg, dot) { - const text = fs +const withoutComments = (spec) => + fs .readFileSync(spec, "utf8") .replace(/\(\*[^]*?\*\)/g, " ") .replace(/\\\*.*/g, " "); + +function warnUnnamedSteps(spec, cfg, dot) { + const text = withoutComments(spec); const specName = /^SPECIFICATION\s+(\w+)/m.exec(cfg)?.[1]; const specBody = specName && new RegExp(`^${specName}\\s*==(.*(\\n[ \\t]+.*)*)`, "m").exec(text); const next = @@ -298,50 +373,87 @@ function warnUnnamedSteps(spec, cfg, dot) { ); } -function readModel(spec, cfgFile) { +function readModel(spec, cfgFile, flag) { if (!fs.existsSync(spec)) throw new CannotFinish(`${rel(spec)} does not exist`); - if (!fs.existsSync(cfgFile)) + if (!fs.existsSync(cfgFile)) { + const beside = fs + .readdirSync(path.dirname(spec)) + .filter((f) => f.endsWith(".cfg")) + .sort(); throw new CannotFinish( - `${rel(cfgFile)} does not exist. It needs SPECIFICATION and one INVARIANT line per rule.`, + `${rel(cfgFile)} does not exist. Pass ${flag} with the config to check ${path.basename(spec)} with. ${ + beside.length + ? `The configs beside it: ${beside.join(", ")}.` + : "A config has SPECIFICATION and one INVARIANT line per rule." + }`, ); + } return { spec, cfgFile, ...splitRules(fs.readFileSync(cfgFile, "utf8")) }; } -function readInput(spec, expected) { - const model = readModel(spec, cfgOf(spec)); - const unknown = [...expected].filter((r) => !model.rules.includes(r)); +function readInput(spec, cfgFile, expected) { + const model = readModel(spec, cfgFile, "--config"); + const names = [...model.rules, ...model.properties]; + const unknown = [...expected].filter((r) => !names.includes(r)); if (unknown.length) throw new CannotFinish( - `--expect-broken ${unknown.join(", ")}: not a rule in ${rel(model.cfgFile)}, whose rules are ${model.rules.join(", ") || "none"}.`, + `--expect-broken ${unknown.join(", ")}: not a rule in ${rel(model.cfgFile)}, whose rules are ${names.join(", ") || "none"}.`, ); return model; } -function check(tools, { spec, cfgFile, cfgWithoutRules, rules }, work, expected) { +function check(tools, model, { work, out }, expected) { + const { spec, cfgFile, cfgWithoutRules, rules, properties } = model; + const started = Date.now(); + let runs = 0; + const run = (cfg, file, options) => { + runs++; + return tlc(tools, spec, cfg, file, options); + }; const name = path.basename(spec, ".tla"); const tlcDir = path.join(work, "tlc"); const dot = path.join(tlcDir, `${name}.dot`); - tlc(tools, spec, cfgWithoutRules, path.join(tlcDir, name), dot); + // One worker writes the graph in a fixed order, which breaks ties between equally short paths. + run(cfgWithoutRules, path.join(tlcDir, name), { dump: dot }); warnUnnamedSteps(spec, cfgWithoutRules, dot); - console.log(`TLC on ${path.basename(spec)}:`); + console.log(`TLC on ${path.basename(spec)} with ${rel(cfgFile)}:`); if (rules.length === 0) - console.log(` ${path.basename(cfgFile)} has no INVARIANT, so no rule is checked.`); - // With -continue TLC reports every bad state. The CONSTRAINT stops each path at its first bad - // state, so a rule that stays broken reports only the step that broke it, not every later state. + console.log( + ` ${path.basename(cfgFile)} has no INVARIANT, so ${properties.length ? "the chart marks no state broken" : "no rule is checked"}.`, + ); + const said = (rule, broken) => + ` ${rule} ${broken ? (expected.has(rule) ? "is broken (expected)" : "is broken") : "holds"}.`; + const shown = (file) => rel(path.join(out, "tlc", path.basename(file))); const traces = rules.map((rule) => { const file = path.join(tlcDir, `${name}-${rule}`); const cfg = `${cfgWithoutRules}INVARIANT ${rule}\nCONSTRAINT ${rule}\n`; - const broken = /^Error: Invariant /m.test(tlc(tools, spec, cfg, file)); + let result = run(cfg, file, { workers: "auto" }); + // Several workers can reach a bad state by a longer path first. One worker searches breadth + // first in a fixed order, so the chart's bug paths are the shortest, and the same every run. + if (/^Error: Invariant /m.test(result.stdout)) result = run(cfg, file); + const broken = /^Error: Invariant /m.test(result.stdout); + console.log(said(rule, broken)); + if (result.warning) console.error(`${shown(`${file}.out`)}: ${result.warning}`); + return { file: `${file}.out`, rule, broken }; + }); + const temporal = properties.map((property) => { + const file = path.join(tlcDir, `${name}-${property}`); + const { stdout } = run(`${cfgWithoutRules}PROPERTY ${property}\n`, file, { workers: "auto" }); + const broken = BROKEN_PROPERTY.test(stdout); console.log( - ` ${rule} ${broken ? (expected.has(rule) ? "is broken (expected)" : "is broken") : "holds"}.`, + `${said(property, broken)}${broken ? ` The chart draws only INVARIANT rules, so read TLC's counterexample in ${shown(`${file}.out`)}.` : ""}`, ); - return { file: `${file}.out`, rule, broken }; + return { file: `${file}.out`, rule: property, broken }; }); - return { spec, dot, traces, broken: traces.some((t) => t.broken) }; + const seconds = ((Date.now() - started) / 1000).toFixed(1); + console.log(` TLC ran ${runs} ${runs === 1 ? "time" : "times"} in ${seconds} s.`); + const broken = [...traces, ...temporal].some((t) => t.broken); + return { spec, dot, traces, properties: temporal, broken }; } function rulesAsExpected(now, other, expected) { - const brokenIn = (m) => (m?.traces ?? []).filter((t) => t.broken).map((t) => t.rule); + const brokenIn = (m) => + [...(m?.traces ?? []), ...(m?.properties ?? [])].filter((t) => t.broken).map((t) => t.rule); if (expected.size === 0) return !now.broken && !other?.broken; const unexpected = [...new Set([...brokenIn(now), ...brokenIn(other)])].filter( (r) => !expected.has(r), @@ -354,9 +466,19 @@ function rulesAsExpected(now, other, expected) { return !unexpected.length && !holds.length; } -const SCAFFOLD = (name, graph) => { - const vars = Object.keys([...graph.nodes.values()][0]?.vars ?? {}); - const actions = [...new Set([...graph.edges, ...graph.loops].map((e) => e.action))].sort(); +const SCAFFOLD = (name, spec) => { + const text = withoutComments(spec); + const vars = tlaVariables(text); + // A definition that only another action calls is listed too, though TLC names no step after it. + const actions = [ + ...new Set( + text + .split(/\n(?=\S)/) + .map((d) => /^(\w+)\s*(?:\([^)]*\))?\s*==([^]*)/.exec(d)) + .filter((d) => d && PRIMES_OR_UNCHANGED.test(d[2])) + .map((d) => d[1]), + ), + ].sort(); return `// Chart labels for ${name}, for statechart.mjs. Write labels in plain words, not variable or action names. // v, before, and after map each variable to its value. Strings, numbers, and booleans keep their kind. // Sequences, tuples, and sets become arrays, indexed from 0. Records and functions become objects. @@ -416,7 +538,7 @@ export function step(action, before, after) { `; }; -async function draw({ now, other, map, work, out, title, comparing }) { +async function draw({ now, other, uncited, map, work, out, title, comparing }) { const shown = published(work, out); const mapping = await import(pathToFileURL(path.resolve(map)).href); const from = ["--dot", now.dot, "--map", map, "--spec", now.spec]; @@ -424,7 +546,10 @@ async function draw({ now, other, map, work, out, title, comparing }) { if (other) { from.push(`--${other.role}-dot`, other.dot, "--spec", other.spec); from.push(...other.traces.flatMap((t) => [`--${other.role}-traces`, t.file])); + if (other.traces.length) + from.push(`--${other.role}-rules`, other.traces.map((t) => t.rule).join(",")); if (other.label !== undefined) from.push("--label", other.label); + if (other.listed) from.push("--listed", JSON.stringify(other.listed)); } if (comparing) from.push("--full-labels", "--flat"); else if (mapping.readerActions?.length) from.push("--cover"); @@ -455,6 +580,8 @@ async function draw({ now, other, map, work, out, title, comparing }) { map, ); const summary = JSON.parse(fs.readFileSync(files.summary, "utf8")); + if (uncited) + fs.writeFileSync(files.summary, `${JSON.stringify({ ...summary, uncited }, null, 2)}\n`); const charts = [files.overview]; for (const c of summary.classes) { const views = typeof mapping.detail === "function" ? ["", "-developer", "-layered"] : [""]; @@ -496,7 +623,7 @@ async function draw({ now, other, map, work, out, title, comparing }) { console.log( summary.classes.length ? fs.readFileSync(files.stories, "utf8").trimEnd() - : "No rule is broken.", + : "No state breaks a rule.", ); return { files, charts }; } @@ -522,6 +649,7 @@ function render(charts, shown) { } function finish({ work, out, inputs }, { files, charts }, ok, png) { + unchanged(inputs, out); const pngs = png ? render(charts, published(work, out)) : []; publish(work, out, inputs); const written = [...charts, ...pngs, files.stories, files.summary] @@ -584,7 +712,9 @@ async function inWorkFolder(body) { try { return await body(work); } catch (e) { - keep = e instanceof TlcStopped; + keep = e instanceof TlcStopped || e instanceof NotPublished; + if (e instanceof NotPublished) + e.message += ` The files that this run wrote are in ${rel(work)}.`; throw e; } finally { if (!keep) fs.rmSync(work, { recursive: true, force: true }); @@ -597,10 +727,33 @@ const moved = (from, to, ...missed) => { return true; } catch (e) { if (missed.includes(e.code)) return false; + if (!["ENOSYS", "EXDEV", "EISDIR"].includes(e.code)) throw e; + } + // Some filesystems, such as FUSE mounts, cannot rename a folder that has files. They fail with + // ENOSYS, or with EISDIR when the target exists. + if (!fs.existsSync(from) && missed.includes("ENOENT")) return false; + if (fs.existsSync(to)) { + if (fs.readdirSync(to).length && missed.includes("ENOTEMPTY")) return false; + fs.rmdirSync(to); + } + try { + fs.cpSync(from, to, { recursive: true, errorOnExist: true, force: false }); + } catch (e) { + fs.rmSync(to, { recursive: true, force: true }); throw e; } + fs.rmSync(from, { recursive: true, force: true }); + return true; }; +function unchanged(inputs, out) { + const changed = [...inputs].find(([f, was]) => fingerprint(f) !== was)?.[0]; + if (changed) + throw new NotPublished( + `${rel(changed)} changed while this run read it, so the run left ${rel(out)} as it was. Run the command again.`, + ); +} + function publish(work, out, inputs) { const parent = path.dirname(path.resolve(out)); fs.mkdirSync(parent, { recursive: true }); @@ -609,11 +762,7 @@ function publish(work, out, inputs) { let aside = false; try { fs.cpSync(work, next, { recursive: true }); - const changed = [...inputs].find(([f, was]) => fingerprint(f) !== was)?.[0]; - if (changed) - throw new CannotFinish( - `${rel(changed)} changed while this run read it, so the run left ${rel(out)} as it was. Run the command again.`, - ); + unchanged(inputs, out); refuseForeign(out); while (!moved(next, out, "ENOTEMPTY", "EEXIST")) { if (aside) { @@ -629,12 +778,12 @@ function publish(work, out, inputs) { fs.rmSync(next, { recursive: true, force: true }); const why = e instanceof CannotFinish ? e.message : `cannot replace ${rel(out)}: ${e.message}.`; if (!back) - throw new CannotFinish( + throw new NotPublished( `${why} This run moved the previous ${rel(out)} to ${rel(old)} and could not put it back.`, ); - throw e instanceof CannotFinish - ? e - : new CannotFinish(`${why} The run left ${rel(out)} as it was.`); + throw new NotPublished( + e instanceof CannotFinish ? why : `${why} The run left ${rel(out)} as it was.`, + ); } if (aside) fs.rmSync(old, { recursive: true, force: true }); } @@ -647,7 +796,17 @@ function putBack(old, out) { } } -function secondModel(input, { fixed, against, label }) { +function secondCfg(cfg, flags) { + if (flags["fixed-config"] && !flags.fixed) + throw new CannotFinish(`--fixed-config needs --fixed. ${more("from-spec")}`); + if (flags["against-config"] && !flags.against) + throw new CannotFinish(`--against-config needs --against. ${more("from-spec")}`); + if (flags.fixed) + return flags["fixed-config"] ?? (fs.existsSync(cfgOf(flags.fixed)) ? cfgOf(flags.fixed) : cfg); + return flags.against && (flags["against-config"] ?? cfgOf(flags.against)); +} + +function secondModel(input, cfg, { fixed, against, label }) { if (fixed && against) throw new CannotFinish(`use --fixed or --against, not both. ${more("from-spec")}`); if (!fixed && !against) { @@ -658,38 +817,91 @@ function secondModel(input, { fixed, against, label }) { } const [role, spec] = fixed ? ["fixed", fixed] : ["against", against]; const name = path.basename(input, ".tla"); - // TLC's output files are named after the spec, so a second model with the same name overwrites them. if (path.basename(spec, ".tla") === name) throw new CannotFinish( `${rel(spec)} needs its own file and module name, such as ${name}${fixed ? "Fixed" : "Agreed"}.tla.`, ); - const cfg = fs.existsSync(cfgOf(spec)) ? cfgOf(spec) : cfgOf(input); - return { role, label, model: readModel(spec, cfg) }; + return { role, label, model: readModel(spec, cfg, `--${role}-config`) }; +} + +function warnUnchecked(now, other) { + const unchecked = now.rules.filter((r) => !other.rules.includes(r)); + if (unchecked.length) + console.error( + `${rel(other.cfgFile)} does not check ${unchecked.join(", ")}, so the stories cannot say whether ${path.basename(other.spec)} keeps ${unchecked.length === 1 ? "it" : "them"}.`, + ); +} + +function listedBreaks(spec) { + const dir = path.dirname(spec); + const files = fs.readdirSync(dir).sort(); + const modules = files.filter((f) => f.endsWith(".tla") && !f.includes("_TTrace_")); + const only = modules.length === 1 ? path.basename(modules[0], ".tla") : undefined; + const breaks = {}; + let headed = false; + for (const f of files.filter((f) => f.endsWith(".cfg"))) { + const cfg = fs.readFileSync(path.join(dir, f), "utf8"); + const module = /^\\\* module:[ \t]*(\w+)[ \t]*$/m.exec(cfg)?.[1] ?? only; + const expect = /^\\\* expect:[ \t]*(\w+)[ \t]*$/m.exec(cfg)?.[1]; + if (module !== path.basename(spec, ".tla") || !expect) continue; + headed = true; + if (!/^(pass|deadlock)$|^error/.test(expect)) breaks[f] = expect; + } + return headed ? { spec: path.basename(spec), breaks } : null; +} + +function warnUnlisted(now, other) { + if (other.listed || !now.traces.some((t) => t.broken)) return; + const module = path.basename(other.spec, ".tla"); + console.error( + `No config beside ${rel(other.spec)} has an "\\* expect:" line for ${module}, so the stories cannot say whether ${module} already lists each bug. When ${path.basename(other.spec)} is a copy, pass the spec in its own folder.`, + ); +} + +function warnUncited(models) { + return Object.fromEntries( + models.map(({ spec, traces, properties }) => { + const found = uncitedConditions(fs.readFileSync(spec, "utf8")); + const held = [...traces, ...properties].filter((t) => !t.broken).map((t) => t.rule); + const short = (s) => (s.length > 90 ? `${s.slice(0, 87)}...` : s); + if (found.length && held.length) + console.error( + `${rel(spec)}: ${joinAnd(held)} ${held.length === 1 ? "holds" : "hold"}, but a rule can also hold because the model waits for a condition that the code does not check. No comment gives the file and line of the code that checks ${found.length === 1 ? "this condition" : "these conditions"}:\n${found.map((c) => ` ${rel(spec)}:${c.line} ${c.action}: ${short(c.condition)}`).join("\n")}\nPut that file and line in a comment on each condition, as in \\* pay.ts:120, or remove the condition when no code checks it.`, + ); + return [path.basename(spec), found]; + }), + ); } async function fromSpec(input, { "expect-broken": expected, ...flags }) { const name = path.basename(input, ".tla"); const map = flags.map ?? path.join(path.dirname(input), `${name}.map.mjs`); - const secondSpec = flags.fixed ?? flags.against; - const inputs = snapshot([input, cfgOf(input), map, secondSpec, secondSpec && cfgOf(secondSpec)]); - const second = secondModel(input, flags); - const tools = findTools(); - const model = readInput(input, expected); + const cfg = flags.config ?? cfgOf(input); + const otherCfg = secondCfg(cfg, flags); + const inputs = snapshot([input, cfg, map, flags.fixed ?? flags.against, otherCfg]); + const second = secondModel(input, otherCfg, flags); + const model = readInput(input, cfg, expected); const out = outDir(flags.out, input, name); + if (!fs.existsSync(map)) { + fs.writeFileSync(map, SCAFFOLD(name, input)); + throw new CannotFinish(`Wrote ${rel(map)}. Fill in its labels, then run this command again.`); + } + const tools = findTools(); return inWorkFolder(async (work) => { - const now = check(tools, model, work, expected); - if (!fs.existsSync(map)) { - fs.writeFileSync(map, SCAFFOLD(name, parseDot(fs.readFileSync(now.dot, "utf8")))); - throw new CannotFinish( - `\nWrote ${rel(map)}. Fill in its labels, then run this command again.`, - ); - } + const now = check(tools, model, { work, out }, expected); const other = second && { role: second.role, label: second.label, - ...check(tools, second.model, work, expected), + listed: second.role === "against" ? listedBreaks(second.model.spec) : undefined, + ...check(tools, second.model, { work, out }, expected), }; - const drawn = await draw({ now, other, map, work, out, title: flags.title }); + let uncited; + if (second) { + warnUnchecked(model, second.model); + if (second.role === "against") warnUnlisted(now, other); + uncited = warnUncited(second.role === "fixed" ? [now, other] : [now]); + } + const drawn = await draw({ now, other, uncited, map, work, out, title: flags.title }); return finish({ work, out, inputs }, drawn, rulesAsExpected(now, other, expected), flags.png); }); } @@ -713,10 +925,10 @@ async function fromChart(input, { "expect-broken": expected, ...flags }) { } snapshot([spec, cfgOf(spec), map], inputs); const tools = findTools(); - const model = readInput(spec, expected); + const model = readInput(spec, cfgOf(spec), expected); const out = outDir(flags.out, input, name); return inWorkFolder(async (work) => { - const now = check(tools, model, work, expected); + const now = check(tools, model, { work, out }, expected); const drawn = await draw({ now, map, work, out, title: flags.title, comparing: true }); const compared = script("compare-charts.mjs", [input, drawn.files.overview], input, { okExits: [0, 1], diff --git a/plugins/pstack/skills/statechart/scripts/test/statechart.test.mjs b/plugins/pstack/skills/statechart/scripts/test/statechart.test.mjs index a90c7e4..72782e6 100644 --- a/plugins/pstack/skills/statechart/scripts/test/statechart.test.mjs +++ b/plugins/pstack/skills/statechart/scripts/test/statechart.test.mjs @@ -15,7 +15,6 @@ import { test } from "node:test"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { - checkTlcOutput, findTlc, parseDot, parseMermaid, @@ -23,19 +22,24 @@ import { parseValue, ruleSentences, stateKey, + tlcOutputWarning, + uncitedConditions, } from "../lib.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const scripts = dirname(here); const fixture = (name) => readFileSync(join(here, name), "utf8"); const edges = (r) => r.transitions.map((t) => `${t.from} -> ${t.to} : ${t.label}`).sort(); -const node = (script, args, cwd, env = {}) => - spawnSync(process.execPath, [join(scripts, script), ...args], { +const node = (script, args, cwd, env = {}) => { + const r = spawnSync(process.execPath, [join(scripts, script), ...args], { cwd, encoding: "utf8", env: { ...process.env, ...env }, timeout: 120_000, }); + r.stdout = r.stdout?.replace(/^( {2}TLC ran \d+ times? in )\d+\.\d s\.$/gm, "$1N s."); + return r; +}; test("a transition on a composite leaves from every state inside it", () => { const { regions } = parseMermaid(fixture("upload.mmd")); @@ -109,6 +113,38 @@ Bare == TRUE assert.equal(stateKey({ b: 1, a: { d: 2, c: [3] } }), stateKey({ a: { c: [3], d: 2 }, b: 1 })); }); +test("an action's condition on a variable that the action does not change needs a file and line", () => { + const found = uncitedConditions(`---- MODULE Pay ---- +CONSTANT MaxAge +VARIABLES screen, sent, + paid, clock +Ready == sent > 0 +Tick == clock' = clock + 1 /\\ UNCHANGED <> +Pay == + /\\ screen = "Cart \\* not a comment" + /\\ MaxAge > 0 + /\\ clock < MaxAge (* pay.ts:120 *) + /\\ Ready + \\* checkout.ts:88 + /\\ sent < 3 + /\\ screen' = IF clock > 0 /\\ sent > 0 THEN "Paid" ELSE screen /\\ paid' = TRUE + /\\ UNCHANGED <> +\\* fill.ts:10 +Fill == \\E o \\in 1..3 : /\\ paid = TRUE + /\\ clock > 0 /\\ sent' = o + 1 + /\\ sent = o + /\\ paid => clock > 0 /\\ screen = "Cart" + /\\ UNCHANGED <> +Next == Tick \\/ Pay \\/ Fill +====`); + assert.deepEqual(found, [ + { line: 11, action: "Pay", condition: "Ready" }, + { line: 17, action: "Fill", condition: "paid = TRUE" }, + { line: 18, action: "Fill", condition: "clock > 0" }, + { line: 20, action: "Fill", condition: 'paid => clock > 0 /\\ screen = "Cart"' }, + ]); +}); + test("the legend is not part of the machine", () => { const { regions } = parseMermaid(`stateDiagram-v2 state "Legend" as legend { @@ -244,7 +280,7 @@ test("a rule broken by a start state is a counterexample of one state", () => { /\\ conn = "Offline" ${done}`; - assert.doesNotThrow(() => checkTlcOutput(text, "tlc.out")); + assert.doesNotThrow(() => tlcOutputWarning(text, "tlc.out")); assert.deepEqual(parseTraces(text), [ { invariant: "NeverSyncedWhileOffline", @@ -255,30 +291,30 @@ ${done}`; test("TLC output from a run that failed or stopped early is refused", () => { assert.doesNotThrow(() => - checkTlcOutput( + tlcOutputWarning( `Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n\n${done}`, "ok.out", ), ); assert.throws( () => - checkTlcOutput( + tlcOutputWarning( "Error: The invariant Nope specified in the configuration file\nis not defined in the specification.\n", "cfg.out", ), /cfg\.out: TLC stopped on an error that is not a broken rule/, ); assert.throws( - () => checkTlcOutput("Error: Parsing or semantic analysis failed.\n", "parse.out"), + () => tlcOutputWarning("Error: Parsing or semantic analysis failed.\n", "parse.out"), /not a broken rule/, ); assert.throws( - () => checkTlcOutput(evaluationError, "eval.out"), + () => tlcOutputWarning(evaluationError, "eval.out"), /TLC threw an unexpected exception/, ); assert.throws( () => - checkTlcOutput( + tlcOutputWarning( "Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n", "stopped.out", ), @@ -290,6 +326,58 @@ test("TLC output from a run that failed or stopped early is refused", () => { ); }); +const tlcThrew = (cls, message) => `Error: Evaluating invariant Small failed. +Index 123 out of bounds for length 4 +Error: TLC threw an unexpected exception. +This was probably caused by an error in the spec or model. +See the User Output or TLC Console for clues to what happened. +The exception was a ${cls} +: ${message} +Error: The error occurred when TLC was evaluating the nested +expressions at the following positions: + The error call stack is empty. +`; +const smallBroken = + "Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n\n"; +const stoppedEarly = "9 states generated, 9 distinct states found, 4 states left on queue.\n"; + +test("a rule that TLC finds broken before TLC throws a Java exception keeps its paths, with a warning", () => { + const aioobe = "java.lang.ArrayIndexOutOfBoundsException: Index 123 out of bounds for length 4"; + const oneWorker = `${smallBroken}Error: Invariant Small is violated.\n${tlcThrew( + "java.lang.ArrayIndexOutOfBoundsException", + "Index 123 out of bounds for length 4", + )}\n${stoppedEarly}`; + assert.equal( + tlcOutputWarning(oneWorker, "tlc.out"), + `TLC found that Small is broken, and then failed with ${aioobe}. TLC stopped before it checked every state, so the charts can miss states that break Small. The failure is in TLC, not in the rule. Do not weaken Small because of it. A smaller constant can let TLC finish.`, + ); + const workersInterleaved = `${tlcThrew("java.lang.RuntimeException", aioobe)}${smallBroken}${stoppedEarly}`; + assert.match( + tlcOutputWarning(workersInterleaved, "tlc.out"), + /failed with java\.lang\.ArrayIndexOutOfBoundsException: Index 123/, + ); + assert.deepEqual( + parseTraces(workersInterleaved).map((t) => t.invariant), + ["Small"], + ); + assert.throws( + () => + tlcOutputWarning( + `${smallBroken}${tlcThrew("java.lang.RuntimeException", "util.Assert$TLCRuntimeException: Attempted to apply tuple")}${stoppedEarly}`, + "formula.out", + ), + /formula\.out: TLC stopped on an error that is not a broken rule: Evaluating invariant Small failed\./, + ); + assert.throws( + () => + tlcOutputWarning( + `${tlcThrew("java.lang.ArrayIndexOutOfBoundsException", "Index 123 out of bounds for length 4")}${stoppedEarly}`, + "never-broken.out", + ), + /not a broken rule: Evaluating invariant Small failed\./, + ); +}); + test("a step that changes nothing keeps a state unsettled, and a quote in a reader label survives the chart", () => { const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync( @@ -713,7 +801,7 @@ const NO_TLC = `Cannot find TLC. Download tla2tools.jar from a TLA+ release: Then set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later). `; const FROM_SPEC_USAGE = - "node statechart.mjs from-spec SPEC.tla [--fixed FIXED.tla | --against AGREED.tla] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]"; + "node statechart.mjs from-spec SPEC.tla [--config FILE] [--fixed FIXED.tla [--fixed-config FILE] | --against AGREED.tla [--against-config FILE]] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]"; const FROM_CHART_USAGE = "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]"; const noTlc = { TLA2TOOLS: "", JAVA: "" }; @@ -989,13 +1077,14 @@ test("from-chart checks the agreed chart and draws it back unchanged", needsTlc, assert.equal( r.stdout, `Wrote Upload.tla, Upload.cfg, and Upload.map.mjs from upload.mmd. -TLC on Upload.tla: +TLC on Upload.tla with Upload.cfg: TypeOK holds. + TLC ran 2 times in N s. Chart: 5 states and 8 transitions from 5 model states. 5 work, 0 break a rule. -No rule is broken. +No state breaks a rule. The spec draws upload.mmd back unchanged. @@ -1031,9 +1120,10 @@ test("from-chart finds the design bug, and from-spec with the fix marks it fixed assert.equal( today.stdout, `Wrote Sync.tla, Sync.cfg, and Sync.map.mjs from sync.mmd. -TLC on Sync.tla: +TLC on Sync.tla with Sync.cfg: TypeOK holds. NeverSyncedWhileOffline is broken. + TLC ran 4 times in N s. Chart: 4 states and 8 transitions from 4 model states. 3 work, 1 breaks a rule. @@ -1059,12 +1149,14 @@ Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart. assert.equal(fixed.status, 1, fixed.stderr); assert.equal( fixed.stdout, - `TLC on Sync.tla: + `TLC on Sync.tla with Sync.cfg: TypeOK holds. NeverSyncedWhileOffline is broken. -TLC on SyncFixed.tla: + TLC ran 4 times in N s. +TLC on SyncFixed.tla with Sync.cfg: TypeOK holds. NeverSyncedWhileOffline holds. + TLC ran 3 times in N s. Chart: 4 states and 9 transitions from 4 model states. 3 work today, 0 still wrong with the fix, 1 fixed or new with the fix. @@ -1113,14 +1205,15 @@ Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart. assert.equal( coded.stdout, `Kept Sync.tla. Delete it to write the spec again from sync.mmd. -TLC on Sync.tla: +TLC on Sync.tla with Sync.cfg: TypeOK holds. NeverSyncedWhileOffline holds. + TLC ran 3 times in N s. Chart: 3 states and 5 transitions from 3 model states. 3 work, 0 break a rule. -No rule is broken. +No state breaks a rule. The spec does not draw sync.mmd: states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them): @@ -1216,6 +1309,126 @@ export const step = (action) => ({ Pay: "you pay", Charge: "the bank charges you export const cause = () => "each charge request charges again"; `; +test( + "from-spec checks each PROPERTY in its own TLC run, and names the counterexample of a broken one", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + pay("Pay", `screen = "Paying" /\\ charges = 0 /\\ charges' = 1 /\\ UNCHANGED screen`).replace( + "====", + 'ChargedAtMostOnce == [](charges <= 1)\nEventuallyPaying == <>(screen = "Paying")\n====', + ), + ); + writeFileSync( + join(dir, "Pay.cfg"), + "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\nPROPERTIES ChargedAtMostOnce EventuallyPaying\n", + ); + writeFileSync(join(dir, "Pay.map.mjs"), payMap); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + assert.equal(r.status, 1, r.stderr); + assert.equal( + r.stdout.split("\n\n")[0], + `TLC on Pay.tla with Pay.cfg: + NoDoubleCharge holds. + ChargedAtMostOnce holds. + EventuallyPaying is broken. The chart draws only INVARIANT rules, so read TLC's counterexample in ${join("Pay-statechart", "tlc", "Pay-EventuallyPaying.out")}. + TLC ran 4 times in N s.`, + ); + assert.match(r.stdout, /\nNo state breaks a rule\.\n/); + const tlcDir = join(dir, "Pay-statechart", "tlc"); + assert.match( + readFileSync(join(tlcDir, "Pay-EventuallyPaying.out"), "utf8"), + /^Error: Temporal properties were violated\./m, + ); + assert.deepEqual( + readdirSync(tlcDir) + .filter((f) => f.endsWith(".cfg") && /PROPERT/.test(readFileSync(join(tlcDir, f), "utf8"))) + .sort(), + ["Pay-ChargedAtMostOnce.cfg", "Pay-EventuallyPaying.cfg"], + ); + const expected = node( + "statechart.mjs", + ["from-spec", "Pay.tla", "--expect-broken", "EventuallyPaying"], + dir, + ); + assert.equal(expected.status, 0, expected.stderr); + assert.match(expected.stdout, /\n {2}EventuallyPaying is broken \(expected\)\. /); + }, +); + +test( + "from-spec checks a rule that the config lists as both PROPERTY and INVARIANT once, as the INVARIANT, and charts its bug", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + pay( + "Pay", + `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, + ), + ); + writeFileSync( + join(dir, "Pay.cfg"), + "SPECIFICATION Spec\nPROPERTY NoDoubleCharge\nINVARIANT NoDoubleCharge\n", + ); + writeFileSync(join(dir, "Pay.map.mjs"), payMap); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + assert.equal(r.status, 1, r.stderr); + assert.equal( + r.stdout.split("\n\n")[0], + `TLC on Pay.tla with Pay.cfg: + NoDoubleCharge is broken. + TLC ran 3 times in N s.`, + ); + assert.match(r.stdout, /\nRule broken: `NoDoubleCharge`\n/); + assert.equal( + r.stdout.split("\n").at(-2), + "Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Pay-statechart.", + ); + const unknown = node( + "statechart.mjs", + ["from-spec", "Pay.tla", "--expect-broken", "Nope"], + dir, + ); + assert.match(unknown.stderr, /, whose rules are NoDoubleCharge\.\n/); + }, +); + +test("from-spec reports a broken PROPERTY in each form that TLC reports it in", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + pay( + "Pay", + `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, + ).replace( + "====", + `ChargedAtMostOnce == [](charges <= 1)\nNeverChargedAgain == [][charges' <= 1]_charges\nStartsPaying == screen = "Paying"\n====`, + ), + ); + writeFileSync( + join(dir, "Pay.cfg"), + "SPECIFICATION Spec\nPROPERTIES ChargedAtMostOnce NeverChargedAgain StartsPaying\n", + ); + writeFileSync(join(dir, "Pay.map.mjs"), payMap); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + assert.equal(r.status, 1, r.stderr); + const broken = (p) => + ` ${p} is broken. The chart draws only INVARIANT rules, so read TLC's counterexample in ${join("Pay-statechart", "tlc", `Pay-${p}.out`)}.`; + assert.equal( + r.stdout.split("\n\n")[0], + `TLC on Pay.tla with Pay.cfg: + Pay.cfg has no INVARIANT, so the chart marks no state broken. +${broken("ChargedAtMostOnce")} +${broken("NeverChargedAgain")} +${broken("StartsPaying")} + TLC ran 4 times in N s.`, + ); +}); + test( "a state reached only after a bug takes the bug's color, and the fix edge leaves the state before the changed step", needsTlc, @@ -1274,7 +1487,15 @@ test( "Refunded, charged": "wrong", }); - const withFix = [...args, "--fixed-dot", "PayFixed.dot", "--fixed-traces", "PayFixed.out"]; + const withFix = [ + ...args, + "--fixed-dot", + "PayFixed.dot", + "--fixed-traces", + "PayFixed.out", + "--fixed-rules", + "NoDoubleCharge", + ]; assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); const chart = readFileSync(join(dir, "chart.mmd"), "utf8"); assert.deepEqual(statusOf(chart), { @@ -1301,7 +1522,7 @@ test( ); test( - "from-spec writes a mapping to fill in, then draws each bug in every view the mapping has", + "a rule that the second model does not check reads not checked, and a rule it checks reads unchanged", needsTlc, () => { const dir = mkdtempSync(join(tmpdir(), "statechart-")); @@ -1312,22 +1533,78 @@ test( `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, ), ); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"); + writeFileSync(join(dir, "pay.map.mjs"), payMap); + assert.match(tlc(dir, "Pay").stdout, /Invariant NoDoubleCharge is violated/); + const story = (...extra) => { + const r = node( + "tlc-to-chart.mjs", + [ + ...["--dot", "Pay.dot", "--map", "pay.map.mjs", "--traces", "Pay.out"], + ...["--against-dot", "Pay.dot", ...extra, "--story", "story.md", "--out", "chart.mmd"], + ], + dir, + ); + assert.equal(r.status, 0, r.stderr); + return readFileSync(join(dir, "story.md"), "utf8"); + }; + assert.match( + story(), + /^In the spec you see the same screens and steps, but the spec does not check this rule\.$/m, + ); + assert.match( + story("--against-traces", "Pay.out", "--against-rules", "NoDoubleCharge"), + /^In the spec you see the same screens and steps, so the spec does not change this path\.$/m, + ); + const alone = node( + "tlc-to-chart.mjs", + [ + "--dot", + "Pay.dot", + "--map", + "pay.map.mjs", + "--against-dot", + "Pay.dot", + "--against-traces", + "Pay.out", + ], + dir, + ); + assert.equal(alone.status, 2); + assert.match(alone.stderr, /^--against-traces and --against-rules go together\./); + }, +); + +test( + "from-spec writes a mapping to fill in, then draws each bug in every view the mapping has", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + pay( + "Pay", + `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, + ).replace("Next ==", 'Wait == screen = "Paying" /\\ UNCHANGED vars\nNext =='), + ); writeFileSync( join(dir, "Pay.cfg"), "SPECIFICATION Spec\n\\* The reader's rule.\nINVARIANTS\n NoDoubleCharge\n", ); - const first = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + const first = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, { + TLA2TOOLS: join(dir, "no-tlc.jar"), + }); assert.equal(first.status, 2); - assert.equal(first.stdout, "TLC on Pay.tla:\n NoDoubleCharge is broken.\n"); + assert.equal(first.stdout, ""); assert.equal( first.stderr, - "\nWrote Pay.map.mjs. Fill in its labels, then run this command again.\n", + "Wrote Pay.map.mjs. Fill in its labels, then run this command again.\n", ); const scaffold = readFileSync(join(dir, "Pay.map.mjs"), "utf8"); assert.match(scaffold, /^\/\/ Variables: screen, charges\.$/m); assert.match( scaffold, - /^const STEPS = \{\n\tCharge: "Charge",\n\tPay: "Pay",\n\tRefund: "Refund",\n\};$/m, + /^const STEPS = \{\n\tCharge: "Charge",\n\tPay: "Pay",\n\tRefund: "Refund",\n\tWait: "Wait",\n\};$/m, ); assert.equal(existsSync(join(dir, "Pay-statechart")), false); @@ -1339,8 +1616,9 @@ test( assert.equal(r.status, 1, r.stderr); assert.equal( r.stdout, - `TLC on Pay.tla: + `TLC on Pay.tla with Pay.cfg: NoDoubleCharge is broken. + TLC ran 3 times in N s. Chart: 5 states and 4 transitions from 5 model states. 3 work, 2 break a rule. @@ -1469,6 +1747,8 @@ test( "EditFixed.dot", "--fixed-traces", "EditFixed.out", + "--fixed-rules", + "NoLostEdit", ]; const r = node( "tlc-to-chart.mjs", @@ -1629,20 +1909,22 @@ export const step = (a) => ({ Save: "you press Save", Retry: "the app tries agai export const cause = () => "the app stops after one retry and shows Saved anyway"; `, ); + const base = [ + "from-spec", + "SaveCode.tla", + "--against", + "SaveAgreed.tla", + "--label", + "the agreed chart", + ]; + const noCfg = node("statechart.mjs", base, dir); + assert.equal(noCfg.status, 2); + assert.equal( + noCfg.stderr, + "SaveAgreed.cfg does not exist. Pass --against-config with the config to check SaveAgreed.tla with. The configs beside it: SaveCode.cfg.\n", + ); const run = (...extra) => - node( - "statechart.mjs", - [ - "from-spec", - "SaveCode.tla", - "--against", - "SaveAgreed.tla", - "--label", - "the agreed chart", - ...extra, - ], - dir, - ); + node("statechart.mjs", [...base, "--against-config", "SaveCode.cfg", ...extra], dir); const unknown = run("--expect-broken", "NoLostNote"); assert.equal(unknown.status, 2); @@ -1655,12 +1937,14 @@ export const cause = () => "the app stops after one retry and shows Saved anyway assert.equal(expected.status, 0, expected.stderr); assert.equal( expected.stdout, - `TLC on SaveCode.tla: + `TLC on SaveCode.tla with SaveCode.cfg: TypeOK holds. NoFalseSaved is broken (expected). -TLC on SaveAgreed.tla: + TLC ran 4 times in N s. +TLC on SaveAgreed.tla with SaveCode.cfg: TypeOK holds. NoFalseSaved holds. + TLC ran 3 times in N s. Chart: 4 states and 4 transitions from 10 model states. 3 work, 1 breaks a rule, 0 only in the agreed chart. @@ -1682,6 +1966,14 @@ Broken as expected: NoFalseSaved. Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart. `, ); + const uncitedWarning = `SaveCode.tla: TypeOK holds, but a rule can also hold because the model waits for a condition that the code does not check. No comment gives the file and line of the code that checks these conditions: + SaveCode.tla:8 Retry: screen = "Saving" + SaveCode.tla:9 TimeOut: retries = 1 +Put that file and line in a comment on each condition, as in \\* pay.ts:120, or remove the condition when no code checks it. +`; + const warnings = `No config beside SaveAgreed.tla has an "\\* expect:" line for SaveAgreed, so the stories cannot say whether SaveAgreed already lists each bug. When SaveAgreed.tla is a copy, pass the spec in its own folder. +${uncitedWarning}`; + assert.equal(expected.stderr, warnings); const out = join(dir, "SaveCode-statechart"); const chart = readFileSync(join(out, "chart.mmd"), "utf8"); assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(against\): the agreed chart\n/m); @@ -1704,6 +1996,13 @@ Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart. assert.deepEqual(summary.second.lacks.sort(), ["note.rev", "retries"]); assert.equal(summary.classes[0].against, "Saving, draft"); assert.deepEqual(summary.codeLike, []); + assert.deepEqual(summary.uncited, { + "SaveCode.tla": [ + { line: 8, action: "Retry", condition: 'screen = "Saving"' }, + { line: 9, action: "TimeOut", condition: "retries = 1" }, + ], + }); + assert.equal(summary.second.listed, undefined); const unexpected = run(); assert.equal(unexpected.status, 1, unexpected.stderr); @@ -1731,6 +2030,92 @@ Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart. coded.stdout, /These labels or causes read like code\. Write them in the reader's words in code\.map\.mjs:\n {2}retryCount hit maxRetries\n/, ); + + writeFileSync( + join(dir, "rev.map.mjs"), + readFileSync(join(dir, "SaveCode.map.mjs"), "utf8").replace( + "${v.note.text}`", + "${v.note.text} r${v.note.rev}`", + ), + ); + const lacks = run("--map", "rev.map.mjs", "--expect-broken", "NoFalseSaved"); + assert.equal(lacks.status, 2); + assert.equal( + lacks.stderr, + `${warnings}rev.map.mjs: the agreed chart has no note.rev, and the mapping reads v.note.rev on its states. Label states and steps only from what both models have, or test "rev" in v.note first.\n`, + ); + writeFileSync( + join(dir, "rev.map.mjs"), + readFileSync(join(dir, "SaveCode.map.mjs"), "utf8").replace( + "${v.note.text}`", + '${v.note.text}${"rev" in v.note ? ` r${v.note.rev}` : ""}`', + ), + ); + const tested = run("--map", "rev.map.mjs", "--expect-broken", "NoFalseSaved"); + assert.equal(tested.status, 0, tested.stderr); + + writeFileSync( + join(dir, "SaveBoth.cfg"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NoFalseSaved\nPROPERTY NoFalseSaved\n", + ); + const both = node( + "statechart.mjs", + [ + ...base, + "--config", + "SaveBoth.cfg", + "--against-config", + "SaveBoth.cfg", + "--expect-broken", + "NoFalseSaved", + ], + dir, + ); + assert.equal(both.status, 0, both.stderr); + assert.equal(both.stderr, warnings); + + writeFileSync(join(dir, "SaveAgreedType.cfg"), "SPECIFICATION Spec\nINVARIANT TypeOK\n"); + const unchecked = node( + "statechart.mjs", + [...base, "--against-config", "SaveAgreedType.cfg", "--expect-broken", "NoFalseSaved"], + dir, + ); + assert.equal(unchecked.status, 0, unchecked.stderr); + assert.match( + unchecked.stdout, + /^TLC on SaveAgreed\.tla with SaveAgreedType\.cfg:\n {2}TypeOK holds\.\n/m, + ); + assert.equal( + unchecked.stderr, + `SaveAgreedType.cfg does not check NoFalseSaved, so the stories cannot say whether SaveAgreed.tla keeps it.\n${warnings}`, + ); + + const listedCfg = (rule) => + writeFileSync( + join(dir, "SaveAgreedBug.cfg"), + `\\* module: SaveAgreed\n\\* expect: ${rule}\nSPECIFICATION Spec\nINVARIANT ${rule}\n`, + ); + listedCfg("NoFalseSaved"); + const known = run("--expect-broken", "NoFalseSaved"); + assert.equal(known.status, 0, known.stderr); + assert.equal(known.stderr, uncitedWarning); + assert.match( + known.stdout, + /\nWhy: .*\nListed: SaveAgreedBug\.cfg, a config of SaveAgreed\.tla, expects TLC to break this rule\.\n/, + ); + const listedSummary = JSON.parse(readFileSync(join(out, "summary.json"), "utf8")); + assert.deepEqual(listedSummary.second.listed, { + spec: "SaveAgreed.tla", + breaks: { "SaveAgreedBug.cfg": "NoFalseSaved" }, + }); + assert.deepEqual(listedSummary.classes[0].listed, ["SaveAgreedBug.cfg"]); + listedCfg("TypeOK"); + const fresh = run("--expect-broken", "NoFalseSaved"); + assert.equal(fresh.status, 0, fresh.stderr); + assert.match( + fresh.stdout, + /\nNot listed: no config of SaveAgreed\.tla expects TLC to break this rule\. Its configs expect TLC to break `TypeOK`\.\n/, + ); }, ); @@ -2455,7 +2840,11 @@ test( writeSlowPay(dir, 200, "Receipt"); const second = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); - process.kill(-first.child.pid, "SIGKILL"); + try { + process.kill(-first.child.pid, "SIGKILL"); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } assert.equal(second.status, 0, second.stderr); for (const file of ["chart.mmd", join("tlc", "Pay.dot")]) { const text = readFileSync(join(out, file), "utf8"); @@ -2478,10 +2867,10 @@ const changedMidRun = async ({ wrap = [], env = {} }) => { const [paid, receipt] = await Promise.all([first.done, second.done]); assert.equal(receipt.status, 0, receipt.stderr); assert.equal(paid.status, 2, paid.stderr); - assert.equal( - paid.stderr, - "Pay.tla changed while this run read it, so the run left Pay-statechart as it was. Run the command again.\n", + const [, kept] = paid.stderr.match( + /^Pay\.tla changed while this run read it, so the run left Pay-statechart as it was\. Run the command again\. The files that this run wrote are in (tmp-a\/statechart-\w+)\.\n$/, ); + assert.match(readFileSync(join(dir, kept, "chart.mmd"), "utf8"), /Paid/); const chart = readFileSync(join(out, "chart.mmd"), "utf8"); assert.match(chart, /Receipt/); assert.doesNotMatch(chart, /Paid/); @@ -2538,9 +2927,9 @@ test( writeFileSync(join(out, "notes.md"), "mine\n"); const r = await run.done; assert.equal(r.status, 2); - assert.equal( + assert.match( r.stderr, - "Pay-statechart has a file that this command did not write: notes.md. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + /^Pay-statechart has a file that this command did not write: notes\.md\. A run puts its files in place of the whole output folder, so pass --out with a new folder\. The files that this run wrote are in tmp\/statechart-\w+\.\n$/, ); assert.deepEqual(readdirSync(out), ["notes.md"]); assert.deepEqual(staged(dir), []); @@ -2552,12 +2941,18 @@ const failRenames = `#define _GNU_SOURCE #include #include #include +#include static size_t hits; int rename(const char *from, const char *to) { static int (*real)(const char *, const char *); if (!real) real = dlsym(RTLD_NEXT, "rename"); const char *base = strrchr(to, '/'), *fails = getenv("RENAME_FAILS"); + const char *cannot = getenv("RENAME_CANNOT"); base = base ? base + 1 : to; + if (cannot && strstr(base, "shared")) { + errno = strcmp(cannot, "EXDEV") == 0 ? EXDEV : access(to, F_OK) == 0 ? EISDIR : ENOSYS; + return -1; + } if (fails && strcmp(base, "shared") == 0 && hits < strlen(fails)) { errno = fails[hits++] == 'N' ? ENOTEMPTY : EIO; return -1; @@ -2588,17 +2983,17 @@ test( const back = run("NI"); assert.equal(back.status, 2, back.stderr); - assert.match( - back.stderr, - /^cannot replace shared: EIO: .*\. The run left shared as it was\.\n$/, + const [, kept] = back.stderr.match( + /^cannot replace shared: EIO: .*\. The run left shared as it was\. The files that this run wrote are in (\/\S+)\.\n$/, ); + assert.equal(readFileSync(join(kept, "chart.mmd"), "utf8"), chart); assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), chart); assert.deepEqual(staged(dir), []); const stuck = run("NII"); assert.equal(stuck.status, 2, stuck.stderr); const named = stuck.stderr.match( - /^cannot replace shared: EIO: .*\. This run moved the previous shared to (\.shared-\w+-old) and could not put it back\.\n$/, + /^cannot replace shared: EIO: .*\. This run moved the previous shared to (\.shared-\w+-old) and could not put it back\. The files that this run wrote are in \/\S+\.\n$/, ); assert.ok(named, stuck.stderr); const aside = named[1]; @@ -2608,6 +3003,112 @@ test( }, ); +test( + "a run on a filesystem that cannot rename copies its files into place", + { skip: needsTlc.skip || (!failingRenames && "cannot compile a rename preload") }, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeLamp(dir); + const args = ["from-spec", "Lamp.tla", "--out", "shared"]; + for (const [cannot, label] of [ + ["ENOSYS", "you flip the switch"], + ["EXDEV", "you press the switch"], + ["ENOSYS", "you tap the switch"], + ]) { + writeFileSync( + join(dir, "Lamp.map.mjs"), + `export const state = (v) => (v.on ? "On" : "Off");\nexport const step = () => "${label}";\n`, + ); + const r = node("statechart.mjs", args, dir, { + LD_PRELOAD: failingRenames, + RENAME_CANNOT: cannot, + }); + assert.equal(r.status, 0, r.stderr); + assert.match(readFileSync(join(dir, "shared", "chart.mmd"), "utf8"), new RegExp(label)); + assert.deepEqual(staged(dir), []); + } + }, +); + +test( + "a run whose input changes before --png renders stops before it renders, and keeps its files", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeSlowPay(dir, 20000); + const bin = ownTmp(dir, "bin"); + const rendered = join(dir, "rendered"); + writeFileSync(join(bin, "npx"), `#!/bin/sh\ntouch "${rendered}"\nexit 1\n`, { mode: 0o755 }); + const tmp = ownTmp(dir, "tmp"); + const run = startRun(dir, { + args: ["from-spec", "Pay.tla", "--png"], + env: { TMPDIR: tmp, PATH: `${bin}${delimiter}${process.env.PATH}` }, + }); + await until(() => tlcStarted(tmp)); + const map = join(dir, "Pay.map.mjs"); + writeFileSync(map, `${readFileSync(map, "utf8")}// edited\n`); + const r = await run.done; + assert.equal(r.status, 2, r.stderr); + const [, kept] = r.stderr.match( + /^Pay\.map\.mjs changed while this run read it, so the run left Pay-statechart as it was\. Run the command again\. The files that this run wrote are in (tmp\/statechart-\w+)\.\n$/, + ); + assert.ok(existsSync(join(dir, kept, "chart.mmd"))); + assert.equal(existsSync(rendered), false); + }, +); + +test( + "a rule that TLC finds broken before TLC throws a Java exception is broken, and the run draws the paths TLC printed", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + pay( + "Pay", + `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, + ), + ); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"); + writeFileSync(join(dir, "Pay.map.mjs"), payMap); + writeFileSync( + join(dir, "crash.txt"), + `Error: Invariant NoDoubleCharge is violated.\n${tlcThrew( + "java.lang.ArrayIndexOutOfBoundsException", + "Index 123 out of bounds for length 4", + ).replaceAll("Small", "NoDoubleCharge")}\n${stoppedEarly}`, + ); + const java = join(dir, "java"); + writeFileSync( + java, + `#!/bin/sh +"$REAL_JAVA" "$@" > "$0.$$.out"; s=$? +if grep -q '^Error: Invariant' "$0.$$.out"; then + sed '/^Model checking completed/,$d' "$0.$$.out"; cat "$CRASH"; s=75 +else + cat "$0.$$.out" +fi +rm "$0.$$.out" +exit $s +`, + { mode: 0o755 }, + ); + const r = node( + "statechart.mjs", + ["from-spec", "Pay.tla", "--expect-broken", "NoDoubleCharge"], + dir, + { JAVA: java, TLA2TOOLS: tools.jar, REAL_JAVA: tools.java, CRASH: join(dir, "crash.txt") }, + ); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^ {2}NoDoubleCharge is broken \(expected\)\.$/m); + assert.match( + r.stderr, + /^Pay-statechart\/tlc\/Pay-NoDoubleCharge\.out: TLC found that NoDoubleCharge is broken, and then failed with java\.lang\.ArrayIndexOutOfBoundsException: Index 123 out of bounds for length 4\. .* Do not weaken NoDoubleCharge because of it\./m, + ); + assert.match(r.stdout, /^## 1\. Paying, charged twice$/m); + }, +); + test( "each TLC run writes the standard modules in its own temp folder, so overlapping runs cannot rewrite them under each other", needsTlc, @@ -2643,7 +3144,7 @@ exit $s for (const line of runs) { const [folder, parsed] = line.split(": "); assert.ok(folder.startsWith(join(tmp, "tlc-")), line); - assert.equal(parsed, `Parsing file ${join(folder, "Naturals.tla")}`); + assert.ok(parsed.startsWith(`Parsing file ${folder}/`), line); folders.add(folder); } assert.equal(folders.size, runs.length); @@ -2739,7 +3240,7 @@ test( dir, ); assert.equal(r.status, 0, r.stdout + r.stderr); - assert.match(r.stdout, /\nNo rule is broken\.\n/); + assert.match(r.stdout, /\nNo state breaks a rule\.\n/); for (const [i, [rule, guard, without, story]] of [ [ "ChartsOneSpec", diff --git a/plugins/pstack/skills/statechart/scripts/tlc-to-chart.mjs b/plugins/pstack/skills/statechart/scripts/tlc-to-chart.mjs index ccb68fd..37ae46e 100644 --- a/plugins/pstack/skills/statechart/scripts/tlc-to-chart.mjs +++ b/plugins/pstack/skills/statechart/scripts/tlc-to-chart.mjs @@ -8,19 +8,20 @@ import { GROUP_PALETTE, LEGEND_ID, PLAIN_FILL, - checkTlcOutput, compareCharts, + escapeMermaidQuotes, groupClassDef, groupColorProblem, isRecord, + joinAnd, joinLabels, parseDot, parseMermaid, parseTraces, productOf, - quote, ruleSentences, stateKey, + tlcOutputWarning, wrap, STATUS_CLASSES, } from "./lib.mjs"; @@ -52,16 +53,30 @@ statechart.mjs runs it once for the overview and once for each bug chart. the reader acts. --traces FILE TLC output that holds counterexamples. Repeat it. TLC -continue gives one per bad state. It refuses a file from a TLC run that stopped early or hit - an error other than a broken rule. + an error other than a broken rule. When TLC found a rule broken and then + failed with a Java exception, it keeps the paths that TLC printed and + warns that the chart can miss states that break the rule. --fixed-dot FILE The state graph of the model with the open fix. Each bug's steps replay on it. --fixed-traces FILE TLC output for the same invariants on the fixed model. Repeat it. It marks states fixed or still wrong, and adds the bugs that only the fixed model has after the current model's bugs. + --fixed-rules LIST The invariants that the fixed model's TLC runs check, as in A,B. It goes + with --fixed-traces. A rule that the fixed model does not check stays + as wrong as it is today, and its stories say "not checked". --against-dot FILE The state graph of the spec that the current code must follow. Each bug's steps replay on it. Do not use it with --fixed-dot. --against-traces FILE TLC output for the spec. Repeat it. Its bugs are drawn wrong, and the bugs that only the spec has number after the code's bugs. + --against-rules LIST The invariants that the spec's TLC runs check. It goes with + --against-traces. Stories of a rule that the spec does not check say + "not checked". + --listed JSON What the spec's configs expect, as in + {"spec":"Spec.tla","breaks":{"A.cfg":"Rule"}}: each config whose + "\\* expect:" line names a rule, and that rule. It goes with + --against-dot. Each story then says whether a config of the spec already + expects TLC to break its rule, and summary.json has second.listed and + each bug's listed configs. --label TEXT The name of the second model in the chart and the stories. Default: "the fix" with --fixed-dot, "the spec" with --against-dot. --spec FILE A TLA+ file whose \\* comment lines above a rule are the rule's sentence in @@ -100,10 +115,11 @@ take a step that the reader sees. The second model may have other variables and actions than the current model. Variables and record fields that one start state has and the other does not are left out when the two models -are compared. The second model's states take the current model's start values for the variables -it does not have, so one mapping draws both. A step of an action that the second model does not -have is skipped when it changes no shared variable, and matches any step to the same shared state -when it does. +are compared. One mapping draws both models, so it may read only what both have. The command +stops when the mapping reads a variable or field of the current model on a state of the second +model that lacks it. Test "name" in v first to label such a state another way. A step of an +action that the second model does not have is skipped when it changes no shared variable, and +matches any step to the same shared state when it does. In the customer view, states whose labels read "context: progress" with the same context sit in one box named context, and each shows only its progress. A context with one state stays a plain @@ -148,8 +164,9 @@ Writes only the files that --out, --story, and --summary name. Exit codes 0 It wrote the chart. - 1 It cannot use an input: a mapping without state(), TLC output that it refuses, or a - --highlight with no such bug. The error says why. + 1 It cannot use an input: a mapping without state(), a mapping that reads what the second + model lacks, TLC output that it refuses, or a --highlight with no such bug. The error says + why. 2 A bad argument.`; const more = "Run node tlc-to-chart.mjs --help for the flags."; @@ -157,6 +174,8 @@ const args = { traces: [], fixedTraces: [], againstTraces: [], + fixedRules: [], + againstRules: [], specs: [], view: "customer", highlight: 1, @@ -172,8 +191,11 @@ for (let i = 2; i < process.argv.length; i++) { else if (a === "--traces") args.traces.push(next()); else if (a === "--fixed-dot") args.fixedDot = next(); else if (a === "--fixed-traces") args.fixedTraces.push(next()); + else if (a === "--fixed-rules") args.fixedRules.push(...next().split(",")); else if (a === "--against-dot") args.againstDot = next(); else if (a === "--against-traces") args.againstTraces.push(next()); + else if (a === "--against-rules") args.againstRules.push(...next().split(",")); + else if (a === "--listed") args.listed = next(); else if (a === "--label") args.label = next(); else if (a === "--spec") args.specs.push(next()); else if (a === "--view") args.view = next(); @@ -204,6 +226,29 @@ if (!["customer", "developer", "layered"].includes(args.view)) if (args.fixedDot && args.againstDot) fail("use --fixed-dot or --against-dot, not both."); if (args.fixedTraces.length && !args.fixedDot) fail("--fixed-traces needs --fixed-dot."); if (args.againstTraces.length && !args.againstDot) fail("--against-traces needs --against-dot."); +if (!args.fixedTraces.length !== !args.fixedRules.length) + fail("--fixed-traces and --fixed-rules go together. The rules are what those TLC runs check."); +if (!args.againstTraces.length !== !args.againstRules.length) + fail( + "--against-traces and --against-rules go together. The rules are what those TLC runs check.", + ); +if (args.listed !== undefined) { + if (!args.againstDot) + fail("--listed says what the spec's configs expect; it needs --against-dot."); + const shape = `--listed must be JSON such as {"spec":"Spec.tla","breaks":{"A.cfg":"Rule"}}`; + try { + args.listed = JSON.parse(args.listed); + } catch (e) { + fail(`${shape}: ${e.message}.`); + } + const { spec, breaks } = isRecord(args.listed) ? args.listed : {}; + if ( + typeof spec !== "string" || + !isRecord(breaks) || + Object.values(breaks).some((r) => typeof r !== "string") + ) + fail(`${shape}.`); +} if (args.label !== undefined && !args.fixedDot && !args.againstDot) fail("--label names the second model; it needs --fixed-dot or --against-dot."); if (args.proof && !args.regions) fail("--proof writes the regions proof; it needs --regions."); @@ -292,23 +337,31 @@ function omit(vars, paths) { } return out; } -function fill(vars, paths, from) { - if (!paths.length) return vars; - const out = structuredClone(vars); - for (const p of paths) { - const [o, f] = [holder(out, p), holder(from, p)]; - if (o && f && p.at(-1) in f) o[p.at(-1)] = structuredClone(f[p.at(-1)]); - } - return out; +function guard(vars, paths, at = "v") { + if (!paths.length || !isRecord(vars)) return vars; + const missing = new Set(); + const deeper = new Map(); + for (const [k, ...rest] of paths) + if (!rest.length) missing.add(k); + else deeper.set(k, [...(deeper.get(k) ?? []), rest]); + return new Proxy(vars, { + get(target, k) { + if (missing.has(k) && !(k in target)) { + const name = `${at}.${k}`.slice(2); + throw new Error( + `${L} has no ${name}, and the mapping reads ${at}.${k} on its states. Label states and steps only from what both models have, or test "${k}" in ${at} first.`, + ); + } + return deeper.has(k) ? guard(target[k], deeper.get(k), `${at}.${k}`) : target[k]; + }, + }); } -function modelOf(graph, lookup = (vars) => vars) { +function modelOf(graph) { const byId = new Map([...graph.nodes.keys()].map((id) => [id, new Set()])); for (const e of [...graph.edges, ...graph.loops]) byId.get(e.from).add(e.action); - const index = new Map( - [...graph.nodes.values()].map((n) => [stateKey(n.raw ?? n.vars), byId.get(n.id)]), - ); - const facts = (vars) => factsFrom(index.get(stateKey(lookup(vars))) ?? new Set()); + const index = new Map([...graph.nodes.values()].map((n) => [stateKey(n.vars), byId.get(n.id)])); + const facts = (vars) => factsFrom(index.get(stateKey(vars)) ?? new Set()); return { graph, facts, @@ -356,16 +409,17 @@ const secondGraph = secondDot ? parseDot(fs.readFileSync(secondDot, "utf8")) : n const secondInit = secondGraph && [...secondGraph.nodes.values()].find((n) => n.init).vars; const lacks = secondGraph ? lacking(mainInit, secondInit) : []; const extra = secondGraph ? lacking(secondInit, mainInit) : []; -const secondFilled = secondGraph && { - ...secondGraph, - nodes: new Map( - [...secondGraph.nodes].map(([id, n]) => [ - id, - { ...n, raw: n.vars, vars: fill(n.vars, lacks, mainInit) }, - ]), - ), -}; -const second = secondFilled && modelOf(secondFilled, (vars) => omit(vars, lacks)); +const second = + secondGraph && + modelOf({ + ...secondGraph, + nodes: new Map( + [...secondGraph.nodes].map(([id, n]) => [ + id, + { ...n, raw: n.vars, vars: guard(n.vars, lacks) }, + ]), + ), + }); const secondQ = second && quotient(second); function readerPath(trace, key, labelOf = stepLabel) { @@ -461,22 +515,22 @@ function replaySecond(trace) { .filter((t) => t.invariant === trace.invariant) .map((t) => stateKey(t.steps[t.steps.length - 1].vars)), ); - const ruleHolds = - secondTraceFiles.length > 0 && ![...passed].some((id) => bad.has(stateKey(nodeOf(id).vars))); - return { sameEnd: true, ruleHolds }; + const checked = secondRules.has(trace.invariant); + const ruleHolds = checked && ![...passed].some((id) => bad.has(stateKey(nodeOf(id).vars))); + return { sameEnd: true, checked, ruleHolds }; } function readTraces(files, paths = []) { return files.flatMap((f) => { const text = fs.readFileSync(f, "utf8"); - checkTlcOutput(text, f); + const warning = tlcOutputWarning(text, f); + if (warning) console.error(`${f}: ${warning}`); return parseTraces(text).map((t) => ({ ...t, - steps: t.steps.map((s) => ({ ...s, vars: fill(s.vars, paths, mainInit) })), + steps: t.steps.map((s) => ({ ...s, vars: guard(s.vars, paths) })), })); }); } -// With -continue, TLC reports every bad state, including states after the first bad one. function firstBad(raw) { const badStates = new Map(); for (const t of raw) { @@ -497,6 +551,7 @@ function firstBad(raw) { } const rawTraces = readTraces(args.traces); const secondTraceFiles = role === "fix" ? args.fixedTraces : args.againstTraces; +const secondRules = new Set(role === "fix" ? args.fixedRules : args.againstRules); const rawSecondTraces = readTraces(secondTraceFiles, lacks); const traces = firstBad(rawTraces); const secondTraces = firstBad(rawSecondTraces); @@ -592,12 +647,13 @@ function badKeys(m, keys, raw) { return out; } const badNow = badKeys(main, now.keys, rawTraces); -const badSecond = - second && secondTraceFiles.length > 0 - ? badKeys(second, secondQ.keys, rawSecondTraces) - : role === "fix" - ? badNow - : new Set(); +const uncheckedTraces = rawTraces.filter((t) => !secondRules.has(t.invariant)); +const badSecond = second + ? new Set([ + ...badKeys(second, secondQ.keys, rawSecondTraces), + ...(role === "fix" ? badKeys(main, now.keys, uncheckedTraces) : []), + ]) + : new Set(); const allNodes = new Set([...now.nodes, ...(secondQ?.nodes ?? [])]); const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]); const onlySecond = (k) => second && !now.nodes.has(k); @@ -770,8 +826,6 @@ const groupCounts = (drawn) => })) .filter((g) => g.states > 0); -// Mermaid shrinks a chart to the page width unless useMaxWidth is false, and rewraps labels that -// wrap() already wrapped unless markdownAutoWrap is false. const frontMatter = (title) => [ "---", ...(title ? [`title: ${JSON.stringify(title)}`] : []), @@ -804,7 +858,7 @@ function head(drawn) { } else for (const c of STATUS_CLASSES) out.push(` ${c}`); if (legend.length) { out.push(` state "Legend" as ${LEGEND_ID} {`); - for (const [id, text] of legend) out.push(` state "${quote(text)}" as ${id}`); + for (const [id, text] of legend) out.push(` state "${escapeMermaidQuotes(text)}" as ${id}`); out.push(" }"); for (const [id, , c] of legend) out.push(` class ${id} ${c}`); } @@ -842,7 +896,7 @@ function regionsChart({ states, starts, edges, classes, proofPath, allPath }) { const proof = [ ...frontMatter(), "stateDiagram-v2", - ...[...ids].map(([s, id]) => ` state "${quote(s)}" as ${id}`), + ...[...ids].map(([s, id]) => ` state "${escapeMermaidQuotes(s)}" as ${id}`), ...[...flat.Main.initial].map((s) => ` [*] --> ${ids.get(s)}`), ...steps.map((s) => ` ${ids.get(bar(s.from))} --> ${ids.get(bar(s.to))} : ${s.label}`), ]; @@ -921,10 +975,11 @@ function regionsChart({ states, starts, edges, classes, proofPath, allPath }) { if (i) out.push(" --"); const name = mapping.regionNames?.[i]; const pad = name ? " " : " "; - if (name) out.push(` state "${quote(wrap(name))}" as region${i} {`); + if (name) out.push(` state "${escapeMermaidQuotes(wrap(name))}" as region${i} {`); for (const s of new Set(starts.map((k) => split(k)[i]))) out.push(`${pad}[*] --> ${rid[i].get(s)}`); - for (const [s, id] of rid[i]) out.push(`${pad}state "${quote(wrap(s))}" as ${id}`); + for (const [s, id] of rid[i]) + out.push(`${pad}state "${escapeMermaidQuotes(wrap(s))}" as ${id}`); const shownMoves = [...moves[i]] .filter(([id]) => !only || only[i].has(id)) .map(([, m]) => m) @@ -1204,12 +1259,13 @@ for (const [box, members] of boxes) { const out = head(drawn); for (const [box, members] of boxes) { - out.push(` state "${quote(wrap(box))}" as ${boxIds.get(box)} {`); - for (const k of members) out.push(` state "${quote(wrap(caption(k)))}" as ${idOf(k)}`); + out.push(` state "${escapeMermaidQuotes(wrap(box))}" as ${boxIds.get(box)} {`); + for (const k of members) + out.push(` state "${escapeMermaidQuotes(wrap(caption(k)))}" as ${idOf(k)}`); out.push(" }"); } for (const k of sorted) - if (!boxOf.has(k)) out.push(` state "${quote(wrap(caption(k)))}" as ${idOf(k)}`); + if (!boxOf.has(k)) out.push(` state "${escapeMermaidQuotes(wrap(caption(k)))}" as ${idOf(k)}`); for (const k of sorted) if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) out.push(` [*] --> ${idOf(k)}`); @@ -1229,9 +1285,11 @@ function secondLine(c) { const s = c.second; if (!s) return null; if (s.sameEnd) - return s.ruleHolds - ? `${W} ${L} you see the same screens and steps, yet the rule holds, so the state labels do not show what ${L} changes.` - : `${W} ${L} you see the same screens and steps, so ${L} does not change this path.`; + return !s.checked + ? `${W} ${L} you see the same screens and steps, but ${L} does not check this rule.` + : s.ruleHolds + ? `${W} ${L} you see the same screens and steps, yet the rule holds, so the state labels do not show what ${L} changes.` + : `${W} ${L} you see the same screens and steps, so ${L} does not change this path.`; if (s.reason) return `${cap(L)} cannot take these steps: it ${s.reason}.`; const outcome = s.disabled ? "cannot happen, so you stay at" @@ -1244,6 +1302,16 @@ function secondLine(c) { } return `${W} ${L}, step ${s.step} (${s.said}) ${outcome}: ${show(s.key)}.`; } +const listedIn = (c) => + Object.keys(args.listed.breaks).filter((cfg) => args.listed.breaks[cfg] === c.invariant); +function listedLine(c) { + const { spec, breaks } = args.listed; + const cfgs = listedIn(c); + if (cfgs.length) + return `Listed: ${joinAnd(cfgs)}, ${cfgs.length === 1 ? "a config" : "configs"} of ${spec}, expect${cfgs.length === 1 ? "s" : ""} TLC to break this rule.`; + const others = [...new Set(Object.values(breaks))].sort().map((r) => `\`${r}\``); + return `Not listed: no config of ${spec} expects TLC to break this rule. ${others.length ? `Its configs expect TLC to break ${joinAnd(others)}.` : "None of its configs expects TLC to break a rule."}`; +} if (args.story) { const lines = []; const header = role === "fix" ? `New with ${L}: ` : `Only in ${L}: `; @@ -1251,6 +1319,7 @@ if (args.story) { lines.push(`## ${i + 1}. ${c.introduced ? header : ""}${show(c.end)}`, ""); lines.push(`Rule broken: ${ruleOf(c)}`); if (c.cause) lines.push(`Why: ${c.cause.replace(/\.$/, "")}.`); + if (args.listed) lines.push(listedLine(c)); lines.push( c.count === 1 ? "This is the only way to get here in the model." @@ -1282,14 +1351,25 @@ const secondOf = (c) => : `only in ${L}` : c.second ? c.second.sameEnd - ? c.second.ruleHolds - ? "same screens, but the rule holds" - : "unchanged" + ? !c.second.checked + ? "same screens, rule not checked" + : c.second.ruleHolds + ? "same screens, but the rule holds" + : "unchanged" : (c.second.reason ?? show(c.second.key)) : null; const summary = { view: args.view, - ...(role ? { second: { role, label: L, lacks: lacks.map((p) => p.join(".")) } } : {}), + ...(role + ? { + second: { + role, + label: L, + lacks: lacks.map((p) => p.join(".")), + ...(args.listed ? { listed: args.listed } : {}), + }, + } + : {}), modelStates: graph.nodes.size, modelTransitions: graph.edges.length, chartStates: regions?.mermaid ? regions.states[0] + regions.states[1] : flatStates.size, @@ -1331,6 +1411,7 @@ const summary = { modelSteps: c.trace.steps.length, counterexamples: c.count, [role ?? "fix"]: secondOf(c), + ...(args.listed ? { listed: listedIn(c) } : {}), })), codeLike, }; From d516cdd582c227648f69c21b1328d2a5f061e697 Mon Sep 17 00:00:00 2001 From: Negrescu Alexandru Date: Thu, 22 Oct 2026 23:36:17 +0300 Subject: [PATCH 05/10] fix(port): carry upstream setup step 8 (formal verification offer) into the owned setup-pstack --- plugins/pstack/skills/setup-pstack/SKILL.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/pstack/skills/setup-pstack/SKILL.md b/plugins/pstack/skills/setup-pstack/SKILL.md index f309bd5..ed2f086 100644 --- a/plugins/pstack/skills/setup-pstack/SKILL.md +++ b/plugins/pstack/skills/setup-pstack/SKILL.md @@ -59,3 +59,7 @@ Report the exact aliases and agent keys written, the selectors and effort suffix ### 7. Offer a verification skill (optional) Check whether the project has a way to drive the real app for proof. If not, offer once: "want a project-local verification skill, so agents can drive the app the way a user does and prove changes work? I can generate one." On yes, read `skill://create-verification-skill` in full and follow it. On no, move on without pushing. + +### 8. Offer a formal verification skill (optional) + +Check whether the project already has a formal verification skill (a project skill that runs a model checker, a proof, or another verifier against the code). If not, offer once: "want a formal verification skill, so agents can prove a key property of your code holds in every case in scope, not only the cases tests reach? I can generate one." On yes, read `skill://create-formal-verification-skill` in full and follow it. On no, move on without pushing. From 27cbeb39916071cd13f420a3fc2f1f4ac3d9b5bd Mon Sep 17 00:00:00 2001 From: Negrescu Alexandru Date: Fri, 23 Oct 2026 00:00:40 +0300 Subject: [PATCH 06/10] slice c --- .github/workflows/gate.yml | 29 ++++ omp-port/smoke-plugin.mjs | 158 ++++++++++++++++++ plugins/pstack/extensions/potetomode/index.js | 14 +- .../extensions/potetomode/index.test.js | 13 ++ 4 files changed, 209 insertions(+), 5 deletions(-) create mode 100755 omp-port/smoke-plugin.mjs diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index e937543..a77680d 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -42,3 +42,32 @@ jobs: - name: Extension tests working-directory: plugins/pstack run: bun test ./extensions/potetomode/index.test.js ./extensions/pstackpolicy/index.test.js + + smoke: + # A real omp loads plugins/pstack: every skill directory must register as a command, and the + # pin extension must record and reload its state. omp-port/smoke-plugin.mjs names each check. + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + - name: Install pinned omp + # can1357/oh-my-pi release v18.12.6, asset omp-linux-x64, sha256 + # 85d9daf3a05fc76e8cd425ae1904eb576af8d21cc85fcf4690742626e07ccfa4. That is the digest + # `gh api repos/can1357/oh-my-pi/releases/latest` reports for the asset and the sha256 of the + # downloaded file, checked 2026-10-22. A bump changes the tag and the digest together. + env: + OMP_TAG: v18.12.6 + OMP_SHA256: 85d9daf3a05fc76e8cd425ae1904eb576af8d21cc85fcf4690742626e07ccfa4 + run: | + dir="$RUNNER_TEMP/omp" + mkdir -p "$dir" + curl -fsSL --retry 3 -o "$dir/omp" \ + "https://github.com/can1357/oh-my-pi/releases/download/$OMP_TAG/omp-linux-x64" + echo "$OMP_SHA256 $dir/omp" | sha256sum --check - + chmod +x "$dir/omp" + echo "$dir" >> "$GITHUB_PATH" + - name: Plugin smoke + run: OMP_SMOKE_REQUIRED=1 bun omp-port/smoke-plugin.mjs diff --git a/omp-port/smoke-plugin.mjs b/omp-port/smoke-plugin.mjs new file mode 100755 index 0000000..050ef8e --- /dev/null +++ b/omp-port/smoke-plugin.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env bun +// Loads the plugin tree into a real omp and checks what only omp can answer: that every skill +// directory registers as a command, and that the pin extension records and reloads its state. +// +// bun omp-port/smoke-plugin.mjs [plugin-dir] plugin-dir defaults to plugins/pstack +// +// No omp on PATH prints SKIPPED and exits 0, unless OMP_SMOKE_REQUIRED=1 makes that a failure. +import assert from "node:assert"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; + +const plugin = resolve(process.argv[2] ?? fileURLToPath(new URL("../plugins/pstack", import.meta.url))); + +const version = spawnSync("omp", ["--version"], { encoding: "utf8" }); +if (version.error?.code === "ENOENT") { + if (process.env.OMP_SMOKE_REQUIRED === "1") { + console.error("smoke-plugin: FAIL omp is not on PATH and OMP_SMOKE_REQUIRED=1"); + process.exit(1); + } + console.log("smoke-plugin: SKIPPED omp is not on PATH; set OMP_SMOKE_REQUIRED=1 to fail instead"); + process.exit(0); +} +if (version.status !== 0) { + console.error(`smoke-plugin: FAIL omp --version exited ${version.status}: ${version.stderr}`); + process.exit(1); +} + +const skills = (await readdir(join(plugin, "skills"), { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + +// A fresh HOME keeps the operator's plugins, extensions and credentials out. The variables stripped +// below would relocate omp's roots past it. +const home = await mkdtemp(join(tmpdir(), "pstack-smoke-")); +await mkdir(join(home, ".omp", "agent"), { recursive: true }); +// omp refuses to start without a model. A local provider on the discard port satisfies that with +// no credentials, and a turn that did start fails on a refused connection instead of reaching one. +await writeFile( + join(home, ".omp", "agent", "models.yml"), + [ + "providers:", + " smoke:", + " baseUrl: http://127.0.0.1:9/v1", + " auth: none", + " api: openai-completions", + " models:", + " - id: no-inference", + "", + ].join("\n"), +); +const env = { ...process.env, HOME: home, USERPROFILE: home }; +for (const key of Object.keys(env)) { + if (/^(?:OMP_|PI_|XDG_(?:CONFIG|DATA|STATE|CACHE)_HOME$|CLAUDE_CONFIG_DIR$)|API_KEY|TOKEN|SECRET|PASSWORD/i.test(key)) { + delete env[key]; + } +} + +// --plugin-dir adds the tree to skill discovery but does not load package.json omp.extensions, so +// the same directory also goes in as -e, which does. +const child = spawn( + "omp", + [ + "--mode", "rpc", "--no-session", "--no-title", "--no-lsp", "--no-tools", "--no-rules", + "--no-extensions", "--plugin-dir", plugin, "-e", plugin, "--model", "smoke/no-inference", + ], + { cwd: home, env, stdio: ["pipe", "pipe", "pipe"] }, +); +let stderr = ""; +child.stderr.on("data", (chunk) => { stderr += chunk; }); +let spawnError; +child.on("error", (error) => { spawnError = error; }); +const exited = new Promise((done) => child.once("close", (code) => done(code))); +const lines = createInterface({ input: child.stdout })[Symbol.asyncIterator](); +const timer = setTimeout(() => child.kill(), 60_000); + +const notices = []; +async function frame() { + const { value, done } = await lines.next(); + assert(!done, spawnError?.message ?? "omp exited before the smoke finished"); + const message = JSON.parse(value); + assert(message.type !== "agent_start", "a smoke step started a model turn"); + assert(message.type !== "extension_error", `extension error: ${value}`); + if (message.type === "extension_ui_request" && message.method === "notify") notices.push(message.message); + return message; +} + +let nextId = 0; +async function request(type, fields = {}) { + const id = String(++nextId); + child.stdin.write(`${JSON.stringify({ id, type, ...fields })}\n`); + for (;;) { + const message = await frame(); + if (message.id !== id) continue; + if (message.type === "response") { + assert(message.success === true, `${type} failed: ${message.error}`); + if (type !== "prompt" || message.data?.agentInvoked === false) return message.data; + } + if (message.type === "prompt_result") { + assert(message.agentInvoked === false, `${fields.message} reached the agent`); + assert(message.status === "completed", `${fields.message} ended ${message.status}`); + return message; + } + } +} + +const pinEntry = async () => + (await request("get_entries")).entries + .filter((entry) => entry.type === "custom" && entry.customType === "poteto-mode") + .at(-1)?.data?.pinned; + +try { + while ((await frame()).type !== "ready"); + + const { commands } = await request("get_available_commands"); + const registered = new Set(commands.filter((c) => c.source === "skill").map((c) => c.name)); + const missing = skills.filter((name) => !registered.has(`skill:${name}`)); + assert( + missing.length === 0, + `${skills.length - missing.length} of ${skills.length} skill directories registered as skill:; ` + + `missing ${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ` and ${missing.length - 5} more` : ""}`, + ); + assert( + commands.some((c) => c.name === "poteto-mode" && c.source === "extension"), + "the potetomode extension did not register /poteto-mode", + ); + + for (const [argument, pinned] of [["on", true], ["off", false]]) { + await request("prompt", { message: `/poteto-mode ${argument}` }); + assert((await pinEntry()) === pinned, `/poteto-mode ${argument} did not record pinned: ${pinned}`); + } + + // /new arrives as session_switch; the pin from the session it left must not follow it. + await request("prompt", { message: "/poteto-mode on" }); + await request("new_session"); + await request("prompt", { message: "/poteto-mode status" }); + assert(notices.at(-1) === "poteto-mode: not pinned", `the pin survived new_session (${notices.at(-1)})`); + + child.stdin.end(); + assert((await exited) === 0, "omp did not exit cleanly"); + console.log( + `smoke-plugin: PASS ${version.stdout.trim()}: ${skills.length}/${skills.length} skills registered; ` + + "/poteto-mode on/off recorded; pin reloaded on new_session; no model turn", + ); +} catch (error) { + console.error(`smoke-plugin: FAIL ${error.message}`); + if (stderr.trim()) console.error(`omp stderr:\n${stderr.trim()}`); + process.exitCode = 1; +} finally { + clearTimeout(timer); + if (child.exitCode === null) child.kill(); + await exited; + await rm(home, { recursive: true, force: true }); +} diff --git a/plugins/pstack/extensions/potetomode/index.js b/plugins/pstack/extensions/potetomode/index.js index 17dc893..527ca45 100644 --- a/plugins/pstack/extensions/potetomode/index.js +++ b/plugins/pstack/extensions/potetomode/index.js @@ -10,7 +10,7 @@ // // Pattern copied from the working reference ~/.omp/agent/extensions/ponytail/index.js: // pi.appendEntry(...) persist across turns without spending context -// session_start + getBranch() restore the pin when a session resumes +// session_* + getBranch() restore the pin from whichever branch the session lands on // before_agent_start -> systemPrompt inject the reminder every run // pi.registerCommand(...) /poteto-mode as a slash command // c.ui.setStatus(...) live status bar pill/indicator @@ -114,12 +114,16 @@ export default function potetomode(pi) { handler: (ctx) => setPinned(!pinned, ctx), }); - pi.on("session_start", async (_event, ctx) => { - const entries = ctx.sessionManager.getBranch(); - pinned = restore(entries) ?? pi.getFlag?.("poteto") === true; + const reload = async (_event, ctx) => { + pinned = restore(ctx.sessionManager.getBranch()) ?? pi.getFlag?.("poteto") === true; isActive = false; syncStatus(ctx); - }); + }; + // omp://extensions.md names start, branch and tree; /new, /fork and /resume arrive as + // session_switch, and each lands on another branch whose entries decide the pin. + for (const event of ["session_start", "session_switch", "session_branch", "session_tree"]) { + pi.on(event, reload); + } pi.on("agent_start", async (_event, ctx) => { isActive = true; diff --git a/plugins/pstack/extensions/potetomode/index.test.js b/plugins/pstack/extensions/potetomode/index.test.js index 5077ba9..b8bdd79 100644 --- a/plugins/pstack/extensions/potetomode/index.test.js +++ b/plugins/pstack/extensions/potetomode/index.test.js @@ -20,6 +20,7 @@ function host(flag = true) { }); return { start: async (branch = []) => { entries = branch; await events.get("session_start")({}, ctx); }, + emit: async (name, branch = []) => { entries = branch; await events.get(name)?.({}, ctx); }, before: (systemPrompt = ["original"]) => events.get("before_agent_start")({ systemPrompt }), command: (args) => command(args, ctx), shortcut: () => shortcut(ctx), @@ -80,3 +81,15 @@ test("prompt block boundaries and input blocks remain intact", async () => { expect(blocks).toEqual(["first", "second"]); expect(result.systemPrompt).toHaveLength(3); }); + +test("switch, branch and tree navigation reload the pin from the branch they land on", async () => { + for (const event of ["session_switch", "session_branch", "session_tree"]) { + const session = host(false); + await session.start([entry(true)]); + await session.emit(event); + expect(await session.before()).toBeUndefined(); + expect(session.status()).toBe(""); + await session.emit(event, [entry(true)]); + expect((await session.before()).systemPrompt[1]).toContain("poteto-mode is PINNED"); + } +}); From 337d43ad1ba2e810287cb43e75cdcd4f7495fd01 Mon Sep 17 00:00:00 2001 From: Negrescu Alexandru Date: Fri, 23 Oct 2026 00:00:40 +0300 Subject: [PATCH 07/10] slice a --- omp-port/patches/worktree-audit-omp.patch | 273 +++++++++++------ omp-port/test-worktree-audit.sh | 278 ++++++++++++++++++ .../references/session-store-on-omp.md | 29 +- .../poteto-mode/scripts/worktree-audit.sh | 192 +++++++----- 4 files changed, 597 insertions(+), 175 deletions(-) create mode 100755 omp-port/test-worktree-audit.sh diff --git a/omp-port/patches/worktree-audit-omp.patch b/omp-port/patches/worktree-audit-omp.patch index f4f1fcd..7f1af76 100644 --- a/omp-port/patches/worktree-audit-omp.patch +++ b/omp-port/patches/worktree-audit-omp.patch @@ -2,41 +2,40 @@ # rather than prose, so a unified diff is the right layer for all of it. # # The changes, all of them code: -# - read the omp session store, keyed by cwd the way omp keys it, instead of Cursor's -# slugified per-project directory, and move stat and date onto their GNU spellings +# - read the omp session store instead of Cursor's per-project transcript directory, resolved +# the way omp resolves it: an explicit PI_CODING_AGENT_SESSION_DIR is the transcript +# directory itself with no cwd bucket; otherwise the profile (OMP_PROFILE, PI_PROFILE), +# PI_CONFIG_DIR, PI_CODING_AGENT_DIR and an existing XDG_DATA_HOME/omp pick the root, and +# each worktree's bucket is omp's encoding of its canonical path (-tmp- under the temp +# root, checked first, - under home, ---- otherwise, / \ : mapped to -) +# - scan only the buckets of this repo's worktrees, never the whole store, with grep -F, so +# ripgrep is not required and a path with [ or spaces matches literally +# - fail closed: a store that cannot be found or read makes LAST_CHAT "?" and holds the row at +# verify-recent-chat, the bucket playbooks/worktree-cleanup.md already names, never `safe` +# - probe GNU or BSD stat and date once, so the audit runs on Linux and macOS alike +# - keep the PR list in memory, so the audit writes no scratch file at all, inside the repo or +# out of it # - parse `git worktree list` records by stripping the record prefix, so a path containing # spaces survives, and report a lock reason in its own LOCKED column -# - take the scratch file from a per-run directory under a trap, so two concurrent audits -# cannot collide and the file is removed on every exit path # - record an unreadable worktree in a new INSPECT column and bucket it hold-error, rather -# than letting a failed rev-parse read as a clean tree -# - never mark a locked or unreadable worktree `safe` +# than letting a failed rev-parse read as a clean tree, and never mark a locked worktree +# `safe` +# - print all eleven header columns on every row +# - lowercase the Origin PR state too, so upstream's */open|*/draft match holds for both CLIs +# - carry upstream PR cursor/plugins#770 (#459 on #492) ahead of its merge: untracked and +# ignored files count as wip, a closed but unmerged PR is no longer `safe`, the PR whose +# headRefOid is HEAD wins over another PR on the same branch name, and commits that exist +# nowhere else bucket as hold-unpushed # # Upstream's -b/--base flag, its origin/HEAD default, its Origin-first PR fetch with the gh # fallback, its LAST_CHAT column, and its verify-recent-chat bucket are all kept exactly as -# written. playbooks/worktree-cleanup.md names that bucket by name, so replacing it would put the -# port's prose and upstream's out of step. +# written. # # Authored against post-rules text. rules.sed runs first and rewrites the Cursor path in the # comment this patch deletes, so a hunk quoting the raw upstream comment would not apply. +# omp-port/test-worktree-audit.sh is the offline regression test for every change above. --- a/skills/poteto-mode/scripts/worktree-audit.sh +++ b/skills/poteto-mode/scripts/worktree-audit.sh ---- a/skills/poteto-mode/scripts/worktree-audit.sh -+++ b/skills/poteto-mode/scripts/worktree-audit.sh ---- a/skills/poteto-mode/scripts/worktree-audit.sh -+++ b/skills/poteto-mode/scripts/worktree-audit.sh -@@ -1,8 +1,8 @@ - #!/usr/bin/env bash - # Read-only worktree prune audit. Classifies every git worktree by size, merge --# state, uncommitted work, remote/PR state, and the most recent chat that --# operated in it. Emits a table sorted by size with a suggested bucket. Never --# deletes anything; deletion stays a human-gated step in the playbook. -+# state, uncommitted work, and remote/PR state. Emits a table sorted by size -+# with a suggested bucket. Never deletes anything; deletion stays a -+# human-gated step in the playbook. - # - # Usage: worktree-audit.sh [-b|--base ] [repo-path] - # repo-path defaults to the current repo. @@ -28,7 +28,12 @@ git rev-parse --git-dir >/dev/null 2>&1 || { echo "error: not a git repo: $repo" >&2; exit 1; } @@ -51,31 +50,92 @@ if [ -z "$base" ]; then for cand in "$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)" origin/main origin/master; do -@@ -50,7 +55,10 @@ - # PR state by branch, fetched once as [{number, state, head}] with a lowercase - # state. Origin when its CLI can read this repo's PRs, otherwise gh. Empty when - # neither can. +@@ -47,33 +52,94 @@ + esac + git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 || { echo "error: base ref not found: $base" >&2; exit 1; } + +-# PR state by branch, fetched once as [{number, state, head}] with a lowercase +-# state. Origin when its CLI can read this repo's PRs, otherwise gh. Empty when +-# neither can. -prs=$(mktemp) -+scratch_dir="${TMPDIR:-$repo/.pstack-scratch}/worktree-audit" -+mkdir -p "$scratch_dir" -+prs=$(mktemp "$scratch_dir/prs.XXXXXX") -+trap 'rm -f "$prs"' EXIT INT TERM ++# PR state by branch, fetched once as [{number, state, head, head_oid}] with a ++# lowercase state. Origin when its CLI can read this repo's PRs, otherwise gh. ++# Empty when neither can. Held in memory, so the audit writes no scratch file. ++prs="" if command -v origin >/dev/null 2>&1 \ && raw=$(origin pr list --author "@me" --state all --limit 1000 \ --json number,status,headRef 2>/dev/null); then -@@ -61,19 +69,39 @@ +- printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef}]' > "$prs" 2>/dev/null ++ prs=$(printf '%s' "$raw" | jq -c '[.[] | {number, state: (.status // "" | ascii_downcase), head: .headRef, head_oid: ""}]' 2>/dev/null) + elif raw=$(gh pr list --author "@me" --state all --limit 1000 \ +- --json number,state,headRefName 2>/dev/null); then +- printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName}]' > "$prs" 2>/dev/null ++ --json number,state,headRefName,headRefOid 2>/dev/null); then ++ prs=$(printf '%s' "$raw" | jq -c '[.[] | {number, state: (.state // "" | ascii_downcase), head: .headRefName, head_oid: (.headRefOid // "")}]' 2>/dev/null) fi - [ -s "$prs" ] || echo "[]" > "$prs" +-[ -s "$prs" ] || echo "[]" > "$prs" ++[ -n "$prs" ] || prs="[]" -# Transcripts dir: ~/.omp/projects//agent-transcripts. -slug=$(printf '%s' "$main_wt" | sed 's#^/##; s#/#-#g') -transcripts="$HOME/.cursor/projects/$slug/agent-transcripts" -+# omp keys each session tree by cwd. Scan this worktree's bucket and the repo's; walking every -+# bucket would scale with total session history. -+sessions="${PI_CODING_AGENT_DIR:-$HOME/.omp/agent}/sessions" -+bucket() { printf '%s/%s' "$sessions" "$(printf '%s' "${1#"$HOME"}" | sed 's#/#-#g')"; } -+repo_bucket=$(bucket "$main_wt") -+[ -d "$repo_bucket" ] || repo_bucket="$sessions" ++# omp's session store, resolved the way omp resolves it. An explicit ++# PI_CODING_AGENT_SESSION_DIR is the transcript directory itself, with no cwd ++# bucket under it. Otherwise the root is the profile's (OMP_PROFILE, else ++# PI_PROFILE) under ~/${PI_CONFIG_DIR:-.omp}, PI_CODING_AGENT_DIR when no ++# profile is set, or an existing XDG_DATA_HOME/omp when the agent dir is the ++# default, and it holds one bucket per canonical cwd. Only the buckets of this ++# repo's worktrees are scanned; walking every bucket would scale with total ++# session history. A store that cannot be read makes LAST_CHAT "?" and holds ++# the row at verify-recent-chat instead of reading as no chat at all. ++canon() { (CDPATH='' cd -- "$1" 2>/dev/null && pwd -P) || printf '%s\n' "$1"; } ++dash() { printf '%s' "$1" | tr '/\\:' '---'; } ++# Path of $2 relative to $1, failing when $2 is outside it. Like omp, a ++# relative path starting with ".." counts as outside. ++rel_under() { ++ local root="${1%/}" ++ case "$2" in ++ "$root") return 0 ;; ++ "$root"/*) case "${2#"$root"/}" in ..*) return 1 ;; esac; printf '%s' "${2#"$root"/}" ;; ++ *) return 1 ;; ++ esac ++} ++# omp's bucket name for a canonical cwd. The temp root is checked before home. ++bucket_name() { ++ local rel ++ if rel=$(rel_under "$temp_root" "$1"); then printf '%s' "-tmp${rel:+-$(dash "$rel")}" ++ elif rel=$(rel_under "$home_root" "$1"); then printf '%s' "-$(dash "$rel")" ++ else printf '%s' "--$(dash "${1#/}")--"; fi ++} ++session_dirs=() ++if [ -n "${PI_CODING_AGENT_SESSION_DIR:-}" ]; then ++ sessions=$PI_CODING_AGENT_SESSION_DIR ++ session_dirs=("$sessions/") ++else ++ profile=$(printf '%s' "${OMP_PROFILE-${PI_PROFILE-}}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') ++ [ "$profile" = default ] && profile="" ++ config="$HOME/${PI_CONFIG_DIR:-.omp}${profile:+/profiles/$profile}" ++ agent="$config/agent" ++ [ -z "$profile" ] && [ -n "${PI_CODING_AGENT_DIR:-}" ] && agent=$PI_CODING_AGENT_DIR ++ data=$agent ++ xdg="${XDG_DATA_HOME:-}/omp${profile:+/profiles/$profile}" ++ [ "$agent" = "$config/agent" ] && [ -n "${XDG_DATA_HOME:-}" ] && [ -e "$xdg" ] && data=$xdg ++ sessions="$data/sessions" ++ home_root=$(canon "$HOME") ++ temp_root=$(canon "${TMPDIR:-${TMP:-${TEMP:-/tmp}}}") ++ while IFS= read -r path; do ++ dir="$sessions/$(bucket_name "$(canon "$path")")" ++ [ -d "$dir" ] && session_dirs+=("$dir/") ++ done < <(printf '%s\n' "$worktree_records" | awk '/^worktree /{sub(/^worktree /, ""); print}') ++fi ++chat_lookup=ok ++if [ ! -d "$sessions" ]; then ++ chat_lookup=failed ++ echo "warn: no omp session store at $sessions; LAST_CHAT is ? and rows hold at verify-recent-chat" >&2 ++fi ++# GNU and BSD spell file mtime and epoch formatting differently; probe once. ++if stat -c %Y / >/dev/null 2>&1; then mtime() { stat -c %Y "$1"; }; else mtime() { stat -f %m "$1"; }; fi ++if date -d @0 +%s >/dev/null 2>&1; then day() { date -d "@$1" '+%Y-%m-%d'; }; else day() { date -r "$1" '+%Y-%m-%d'; }; fi now=$(date +%s) echo "# base: $base" @@ -83,23 +143,13 @@ +printf "SIZE\tAGE\tMERGED\tDIRTY\tLOCKED\tINSPECT\tREMOTE\tPR\tLAST_CHAT\tBUCKET\tWORKTREE\n" -git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; do -+printf '%s\n' "$worktree_records" | awk '/^worktree /{sub(/^worktree /, ""); print}' | while IFS= read -r wt; do -+ -+ inspect=ok -+ lock=$(printf '%s\n' "$worktree_records" | awk -v target="$wt" ' -+ BEGIN { RS=""; FS="\n" } -+ $1 == "worktree " target { -+ for (i = 1; i <= NF; i++) { -+ if ($i ~ /^locked/) { -+ reason = substr($i, 8) -+ print reason == "" ? "yes" : reason -+ exit -+ } -+ } -+ }') -+ [ -z "$lock" ] && lock="-" ++printf '%s\n' "$worktree_records" | awk ' ++ /^worktree / { if (wt != "") print wt "\t" lock; wt = substr($0, 10); lock = "-" } ++ /^locked/ { lock = substr($0, 8); if (lock == "") lock = "yes" } ++ END { if (wt != "") print wt "\t" lock }' | while IFS=$'\t' read -r wt lock; do [ "$wt" = "$main_wt" ] && continue ++ inspect=ok size=$(du -sh "$wt" 2>/dev/null | awk '{print $1}') - head=$(git -C "$wt" rev-parse HEAD 2>/dev/null) + if ! head=$(git -C "$wt" rev-parse HEAD 2>/dev/null); then @@ -109,62 +159,95 @@ head_ts=$(git -C "$wt" log -1 --format='%ct' HEAD 2>/dev/null || echo 0) age=$([ "$head_ts" -gt 0 ] 2>/dev/null && echo "$(( (now - head_ts) / 86400 ))d" || echo "?") -@@ -82,8 +110,10 @@ +@@ -81,12 +147,12 @@ + # real signal; merge-base only catches fast-forward/rebase merges. git merge-base --is-ancestor "$head" "$base" 2>/dev/null && merged=YES || merged=no - # Distinguish real WIP (tracked edits) from disposable untracked scratch. +- # Distinguish real WIP (tracked edits) from disposable untracked scratch. - porcelain=$(git -C "$wt" status --porcelain 2>/dev/null) - if [ -z "$porcelain" ]; then dirty=clean -+ if ! porcelain=$(git -C "$wt" status --porcelain 2>/dev/null); then +- elif printf '%s\n' "$porcelain" | grep -qv '^??'; then +- dirty="wip:$(printf '%s\n' "$porcelain" | grep -cv '^??')" +- else dirty="scratch:$(printf '%s\n' "$porcelain" | grep -c '^??')"; fi ++ # Treat tracked, untracked, and ignored paths as work that must be held. ++ if ! porcelain=$(git -C "$wt" status --porcelain --untracked-files=all --ignored=matching 2>/dev/null); then + dirty=error + inspect="status-error" + elif [ -z "$porcelain" ]; then dirty=clean - elif printf '%s\n' "$porcelain" | grep -qv '^??'; then - dirty="wip:$(printf '%s\n' "$porcelain" | grep -cv '^??')" - else dirty="scratch:$(printf '%s\n' "$porcelain" | grep -c '^??')"; fi -@@ -103,24 +133,33 @@ - # Most recent chat whose transcript operated in this worktree. Match path - # followed by "/" or a quote so glint-482 does not match glint-482-r37. - last="-"; last_ts=0 ++ else dirty="wip:$(printf '%s\n' "$porcelain" | wc -l | tr -d '[:space:]')"; fi + + branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") + if [ -z "$branch" ]; then remote=detached +@@ -94,33 +160,56 @@ + [ "$(git -C "$wt" rev-parse "origin/$branch" 2>/dev/null)" = "$head" ] \ + && remote=pushed \ + || remote="ahead$(git -C "$wt" rev-list --count "origin/$branch..HEAD" 2>/dev/null)" ++ [ "$remote" = ahead0 ] && remote=behind + else remote=no-remote; fi + +- pr=$([ -n "$branch" ] && jq -r --arg b "$branch" \ +- '.[] | select(.head==$b) | "#\(.number)/\(.state)"' "$prs" 2>/dev/null | head -1) ++ # Several PRs can share a branch name; prefer the one whose head is HEAD. ++ pr_record=$([ -n "$branch" ] && printf '%s' "$prs" | jq -r --arg b "$branch" --arg h "$head" \ ++ '[.[] | select(.head==$b)] | (map(select(.head_oid==$h)) + .)[0] ++ | select(.) | "#\(.number)/\(.state)\t\(.head_oid // "")"' 2>/dev/null) ++ pr=${pr_record%%$'\t'*} ++ pr_head=${pr_record#*$'\t'} + [ -z "$pr" ] && pr="-" + +- # Most recent chat whose transcript operated in this worktree. Match path +- # followed by "/" or a quote so glint-482 does not match glint-482-r37. +- last="-"; last_ts=0 - if [ -d "$transcripts" ]; then - f=$(rg -l -e "${wt}/" -e "${wt}\"" "$transcripts" 2>/dev/null \ - | xargs stat -f '%m %N' 2>/dev/null | sort -rn | head -1) -+ wt_bucket=$(bucket "$wt"); [ -d "$wt_bucket" ] || wt_bucket="$repo_bucket" -+ if [ -d "$repo_bucket" ]; then -+ f=$(rg -l -e "${wt}/" -e "${wt}\"" "$repo_bucket" "$wt_bucket" 2>/dev/null \ -+ | xargs -r stat -c '%Y %n' 2>/dev/null | sort -rn | head -1) - if [ -n "$f" ]; then last_ts=$(echo "$f" | awk '{print $1}') +- if [ -n "$f" ]; then last_ts=$(echo "$f" | awk '{print $1}') - last=$(date -r "$last_ts" '+%Y-%m-%d' 2>/dev/null); fi -+ last=$(date -d @"$last_ts" '+%Y-%m-%d' 2>/dev/null); fi ++ # Most recent chat whose transcript operated in this worktree, under the ++ # path git lists or its canonical spelling. Match path followed by "/" or a ++ # quote so glint-482 does not match glint-482-r37. ++ last="-"; last_ts=0; chat=$chat_lookup ++ if [ "$chat" = ok ] && [ "${#session_dirs[@]}" -gt 0 ]; then ++ cwt=$(canon "$wt") ++ matched=$(grep -RlsF --include='*.jsonl' -e "${wt}/" -e "${wt}\"" -e "${cwt}/" -e "${cwt}\"" \ ++ -- "${session_dirs[@]}") ++ [ $? -le 1 ] || chat=failed ++ while IFS= read -r f; do ++ [ -n "$f" ] || continue ++ ts=$(mtime "$f") || { chat=failed; break; } ++ [ "$ts" -gt "$last_ts" ] && last_ts=$ts ++ done <<<"$matched" fi ++ if [ "$chat" != ok ]; then last="?" ++ elif [ "$last_ts" -gt 0 ]; then last=$(day "$last_ts" 2>/dev/null) || last="?"; fi recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no) -- case "$dirty" in wip:*) bucket=hold-wip ;; *) -- case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) ++ # An exact PR head proves the commits were pushed even if origin has no ++ # branch (deleted after merging, or hosted on a fork). A closed PR still ++ # proves nothing about whether those commits were merged. ++ case "$pr" in */merged) pr_merged=yes ;; *) pr_merged=no ;; esac ++ case "$remote" in ahead*|no-remote|detached) unpushed=yes ;; *) unpushed=no ;; esac ++ [ "$merged" = YES ] && unpushed=no ++ [ -n "$pr_head" ] && [ "$head" = "$pr_head" ] && unpushed=no ++ + case "$dirty" in wip:*) bucket=hold-wip ;; *) + case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) - if [ "$recent" = yes ]; then bucket=verify-recent-chat - elif [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe -- else bucket=review; fi ;; -- esac ;; -- esac -+ if [ "$inspect" != ok ]; then bucket=hold-error -+ elif [ "$lock" != "-" ]; then bucket=hold-locked -+ else -+ case "$dirty" in -+ error) bucket=hold-error ;; -+ wip:*) bucket=hold-wip ;; -+ *) -+ case "$pr" in -+ *OPEN*) bucket=hold-open-pr ;; -+ *) -+ if [ "$recent" = yes ]; then bucket=verify-recent-chat -+ elif [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe -+ else bucket=review; fi ;; -+ esac ;; -+ esac -+ fi ++ if [ "$unpushed" = yes ]; then bucket=hold-unpushed ++ elif [ "$recent" = yes ] || [ "$chat" != ok ]; then bucket=verify-recent-chat ++ elif [ "$merged" = YES ] || [ "$pr_merged" = yes ]; then bucket=safe + else bucket=review; fi ;; + esac ;; + esac ++ # A locked or unreadable worktree is never a candidate, whatever else holds. ++ [ "$lock" != "-" ] && bucket=hold-locked ++ [ "$inspect" != ok ] && bucket=hold-error - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n" \ - "$size" "$age" "$merged" "$dirty" "$remote" "$pr" "$last" "$bucket" "$wt" +- printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n" \ +- "$size" "$age" "$merged" "$dirty" "$remote" "$pr" "$last" "$bucket" "$wt" ++ printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n" \ ++ "$size" "$age" "$merged" "$dirty" "$lock" "$inspect" "$remote" "$pr" "$last" "$bucket" "$wt" done | sort -t$'\t' -k1,1 -rh - +- -rm -f "$prs" diff --git a/omp-port/test-worktree-audit.sh b/omp-port/test-worktree-audit.sh new file mode 100755 index 0000000..49f1e89 --- /dev/null +++ b/omp-port/test-worktree-audit.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +# Offline regression test for the worktree audit that omp-port/patches/worktree-audit-omp.patch +# produces. Port-only file, never upstream. +# +# Usage: bash omp-port/test-worktree-audit.sh [path/to/worktree-audit.sh] +# Defaults to the generated plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh. Pass an +# older build of the script to prove a regression. Exit 0 PASS, 1 when any assertion fails. +# +# Builds a throwaway repo with a local bare origin, a fake gh and origin CLI, and fixture omp +# session stores, then runs the audit under each store layout omp resolves. Needs git and jq. It +# never reaches the network, the real gh, or the real session store. +set -euo pipefail + +PORT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +AUDIT=${1:-$PORT_DIR/../plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh} +case "$AUDIT" in /*) ;; *) AUDIT="$PWD/$AUDIT" ;; esac +[ -f "$AUDIT" ] || { echo "no audit script at $AUDIT" >&2; exit 1; } +for cmd in git jq; do + command -v "$cmd" >/dev/null || { echo "missing dependency: $cmd" >&2; exit 1; } +done + +FIXTURE=$(mktemp -d "${TMPDIR:-/tmp}/worktree-audit-test.XXXXXX") +trap 'chmod -R u+rwX "$FIXTURE" 2>/dev/null; rm -rf "$FIXTURE"' EXIT +FIXTURE=$(cd "$FIXTURE" && pwd -P) + +# HOME and TMPDIR are symlinks, the way macOS hands out /var/folders, so every path git reports is +# the canonical spelling and only a canonicalizing audit finds the bucket omp wrote. TMPDIR sits +# inside HOME so the temp-before-home order is observable. +CH="$FIXTURE/real home" +mkdir -p "$CH/scratch" "$FIXTURE/bin" "$FIXTURE/portable-bin" +ln -s "real home" "$FIXTURE/home" +export HOME="$FIXTURE/home" TMPDIR="$FIXTURE/home/scratch" +CT="$CH/scratch" +unset TMP TEMP CDPATH GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE \ + PI_CODING_AGENT_SESSION_DIR PI_CODING_AGENT_DIR PI_CONFIG_DIR OMP_PROFILE PI_PROFILE XDG_DATA_HOME +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 +export GIT_AUTHOR_NAME=audit GIT_AUTHOR_EMAIL=audit@example.invalid +export GIT_COMMITTER_NAME=audit GIT_COMMITTER_EMAIL=audit@example.invalid +export FIXTURE PR_PROVIDER=gh REAL_STAT REAL_DATE +REAL_STAT=$(command -v stat) +REAL_DATE=$(command -v date) +export PATH="$FIXTURE/bin:$PATH" + +g() { git -C "$1" "${@:2}" >/dev/null 2>&1; } +ORIGIN="$FIXTURE/origin.git" +REPO="$CH/main repo" +git init -q --bare "$ORIGIN" +git init -q "$REPO" +git -C "$REPO" symbolic-ref HEAD refs/heads/main +printf 'build/\n*.log\n' >"$REPO/.gitignore" +printf 'seed\n' >"$REPO/tracked" +g "$REPO" add .gitignore tracked +g "$REPO" commit -qm seed +g "$REPO" remote add origin "$ORIGIN" +g "$REPO" push -q origin main + +wt() { g "$REPO" worktree add -q -b "$1" "$2" main; } +commit() { + printf '%s\n' "$2" >"$1/$2" + g "$1" add "$2" + g "$1" commit -qm "$2" + git -C "$1" rev-parse HEAD +} +for name in open draft closed-pushed closed-unpushed squash local-only untracked ignored tracked \ + locked broken sibling unrelated plain; do + wt "$name" "$CH/$name" +done +RECENT='recent [one].worktree' +wt recent "$CH/$RECENT" +wt nested "$CH/nest/deep:wt" +wt temp "$CT/temp:wt" +wt absolute "$FIXTURE/abs:wt" +g "$REPO" worktree add -q --detach "$CH/detached" main + +OPEN_HEAD=$(git -C "$CH/open" rev-parse HEAD) +g "$CH/open" push -q origin open +CLOSED_PUSHED=$(commit "$CH/closed-pushed" closed) +g "$CH/closed-pushed" push -q origin closed-pushed +CLOSED_UNPUSHED=$(commit "$CH/closed-unpushed" pushed) +g "$CH/closed-unpushed" push -q origin closed-unpushed +commit "$CH/closed-unpushed" local-only >/dev/null +SQUASH=$(commit "$CH/squash" squashed) +g "$CH/squash" push -q origin squash +g "$REPO" merge --squash squash +g "$REPO" commit -qm 'Squash squash' +g "$REPO" push -q origin main +g "$REPO" push -q origin --delete squash +commit "$CH/local-only" unpushed >/dev/null +commit "$CH/detached" detached >/dev/null +printf 'notes\n' >"$CH/untracked/notes.md" +mkdir "$CH/ignored/build" +printf 'bin\n' >"$CH/ignored/build/artifact.bin" +printf 'log\n' >"$CH/ignored/run.log" +printf 'edit\n' >>"$CH/tracked/tracked" +g "$REPO" worktree lock --reason 'keep this worktree' "$CH/locked" +printf 'gitdir: %s/missing\n' "$FIXTURE" >"$CH/broken/.git" +ROWS=19 + +# A closed PR on a reused branch name sits first, so the audit has to prefer the PR whose head is +# HEAD. gh reports uppercase states; the audit lowercases them. +jq -n --arg open "$OPEN_HEAD" --arg cp "$CLOSED_PUSHED" --arg cu "$CLOSED_UNPUSHED" --arg sq "$SQUASH" '[ + {number: 8, state: "CLOSED", headRefName: "squash", headRefOid: ("0" * 40)}, + {number: 1, state: "OPEN", headRefName: "open", headRefOid: $open}, + {number: 5, state: "OPEN", headRefName: "draft", headRefOid: ""}, + {number: 2, state: "CLOSED", headRefName: "closed-pushed", headRefOid: $cp}, + {number: 3, state: "CLOSED", headRefName: "closed-unpushed", headRefOid: $cu}, + {number: 4, state: "MERGED", headRefName: "squash", headRefOid: $sq}]' >"$FIXTURE/gh.json" +printf '%s\n' '[{"number":1,"status":"OPEN","headRef":"open"},{"number":5,"status":"draft","headRef":"draft"},{"number":2,"status":"closed","headRef":"closed-pushed"}]' >"$FIXTURE/origin.json" +cat >"$FIXTURE/bin/gh" <<'SH' +#!/usr/bin/env bash +[ "$PR_PROVIDER" = gh ] || { : >"$FIXTURE/gh-called"; exit 1; } +cat "$FIXTURE/gh.json" +SH +cat >"$FIXTURE/bin/origin" <<'SH' +#!/usr/bin/env bash +[ "$PR_PROVIDER" = origin ] || exit 1 +cat "$FIXTURE/origin.json" +SH +# BSD spellings on any host: GNU stat -c and date -d are refused, stat -f %m and date -r +# are served. And no ripgrep at all. +cat >"$FIXTURE/portable-bin/stat" <<'SH' +#!/usr/bin/env bash +[ "$1" = -f ] && [ "$2" = %m ] || { echo "stat: illegal option" >&2; exit 1; } +"$REAL_STAT" -c %Y "$3" 2>/dev/null || "$REAL_STAT" -f %m "$3" +SH +cat >"$FIXTURE/portable-bin/date" <<'SH' +#!/usr/bin/env bash +case "$1" in +-d) echo "date: illegal option -- d" >&2; exit 1 ;; +-r) "$REAL_DATE" -d "@$2" "$3" 2>/dev/null || "$REAL_DATE" -r "$2" "$3" ;; +*) exec "$REAL_DATE" "$@" ;; +esac +SH +printf '#!/bin/sh\necho "rg: command not found" >&2\nexit 127\n' >"$FIXTURE/portable-bin/rg" +chmod +x "$FIXTURE"/bin/* "$FIXTURE"/portable-bin/* + +# omp's bucket names, written out by hand rather than derived, since they are the contract. +SESSIONS="$HOME/.omp/agent/sessions" +ABS_BUCKET="--$(printf '%s' "${FIXTURE#/}" | tr '/' '-')-abs-wt--" +chat() { mkdir -p "$1" && printf '{"type":"session","cwd":"%s"}\n' "$3" >"$1/$2.jsonl"; } +chat "$SESSIONS/-main repo" repo "$REPO" +chat "$SESSIONS/-recent [one].worktree" own "$CH/recent [one].worktree" +# A session in one worktree that worked in another still marks the other as in use. +printf '{"type":"message","path":"%s/notes.md"}\n' "$CH/sibling" >>"$SESSIONS/-recent [one].worktree/own.jsonl" +chat "$SESSIONS/-nest-deep-wt" own "$CH/nest/deep:wt" +chat "$SESSIONS/-tmp-temp-wt" own "$CT/temp:wt" +chat "$SESSIONS/$ABS_BUCKET" own "$FIXTURE/abs:wt" +chat "$SESSIONS/-another-project" other "$CH/unrelated" +TODAY=$(date '+%Y-%m-%d') + +FAILS=0 +fail() { printf 'FAIL [%s] %s\n' "$LABEL" "$*"; FAILS=$((FAILS + 1)); } +cell() { + awk -F '\t' -v name="/$1" -v col="$2" ' + /^# / { next } + $1 == "SIZE" { for (i = 1; i <= NF; i++) idx[$i] = i; next } + substr($NF, length($NF) - length(name) + 1) == name { print (idx[col] ? $(idx[col]) : ""); n++ } + END { if (n != 1) exit 1 }' "$OUT" +} +expect() { + local got + got=$(cell "$1" "$2") || got="" + [ "$got" = "$3" ] || fail "$1 $2: want '$3', got '$got'" +} +audit() { + LABEL=$1 + shift + OUT="$FIXTURE/$LABEL.tsv" + if ! env "$@" bash "$AUDIT" -b main "$REPO" >"$OUT" 2>"$FIXTURE/$LABEL.err"; then + fail "audit exited non-zero: $(tr '\n' ' ' <"$FIXTURE/$LABEL.err")" + fi +} + +audit gh +awk -F '\t' -v rows="$ROWS" ' + /^# / { next } + $1 == "SIZE" { h++; if (NF != 11) bad = 1; next } + { r++; if (NF != 11) bad = 1 } + END { exit !(h == 1 && r == rows && !bad) }' "$OUT" || + fail "want one 11-column header and $ROWS 11-column rows" +expect open PR '#1/open' +expect open REMOTE pushed +expect open BUCKET hold-open-pr +expect draft BUCKET hold-open-pr +expect closed-pushed PR '#2/closed' +expect closed-pushed BUCKET review +expect closed-unpushed REMOTE ahead1 +expect closed-unpushed BUCKET hold-unpushed +expect squash PR '#4/merged' +expect squash MERGED no +expect squash REMOTE no-remote +expect squash BUCKET safe +expect local-only REMOTE no-remote +expect local-only BUCKET hold-unpushed +expect detached REMOTE detached +expect detached BUCKET hold-unpushed +expect untracked DIRTY wip:1 +expect untracked BUCKET hold-wip +expect ignored DIRTY wip:2 +expect ignored BUCKET hold-wip +expect tracked DIRTY wip:1 +expect tracked BUCKET hold-wip +expect locked LOCKED 'keep this worktree' +expect locked INSPECT ok +expect locked BUCKET hold-locked +expect broken INSPECT status-error +expect broken BUCKET hold-error +expect plain LOCKED - +expect plain INSPECT ok +expect plain LAST_CHAT - +expect plain BUCKET safe +for name in "$RECENT" sibling deep:wt temp:wt abs:wt; do + expect "$name" LAST_CHAT "$TODAY" + expect "$name" BUCKET verify-recent-chat +done +expect unrelated LAST_CHAT - +expect unrelated BUCKET safe + +audit origin PR_PROVIDER=origin +expect open PR '#1/open' +expect open BUCKET hold-open-pr +expect draft PR '#5/draft' +expect draft BUCKET hold-open-pr +expect closed-pushed BUCKET review +[ ! -e "$FIXTURE/gh-called" ] || fail "gh was called although origin answered" + +audit portable PATH="$FIXTURE/portable-bin:$PATH" +for name in "$RECENT" deep:wt temp:wt abs:wt; do + expect "$name" LAST_CHAT "$TODAY" + expect "$name" BUCKET verify-recent-chat +done + +# An explicit session directory holds transcripts directly, with no cwd bucket under it. +chat "$FIXTURE/direct sessions" direct "$CH/plain" +audit explicit-dir PI_CODING_AGENT_SESSION_DIR="$FIXTURE/direct sessions" +expect plain LAST_CHAT "$TODAY" +expect plain BUCKET verify-recent-chat +expect "$RECENT" LAST_CHAT - + +# A profile owns its own store and outranks PI_CODING_AGENT_DIR. +chat "$HOME/.omp/profiles/work/agent/sessions/-plain" profile "$CH/plain" +mkdir -p "$FIXTURE/decoy/sessions" +audit profile OMP_PROFILE=work PI_CODING_AGENT_DIR="$FIXTURE/decoy" +expect plain LAST_CHAT "$TODAY" +expect "$RECENT" LAST_CHAT - + +# An existing XDG_DATA_HOME/omp replaces the default data root, with no agent/ under it. +chat "$FIXTURE/xdg/omp/sessions/-plain" xdg "$CH/plain" +audit xdg XDG_DATA_HOME="$FIXTURE/xdg" +expect plain LAST_CHAT "$TODAY" +expect "$RECENT" LAST_CHAT - + +# A store that cannot be read is unknown, never "no chat": the row holds for verification while +# the stronger holds keep their own bucket. +audit missing-store PI_CODING_AGENT_DIR="$FIXTURE/nowhere" +expect plain LAST_CHAT '?' +expect plain BUCKET verify-recent-chat +expect unrelated BUCKET verify-recent-chat +expect open BUCKET hold-open-pr +expect tracked BUCKET hold-wip +if [ "$(id -u)" != 0 ]; then + chmod 000 "$SESSIONS/-main repo/repo.jsonl" + audit unreadable-transcript + expect plain LAST_CHAT '?' + expect plain BUCKET verify-recent-chat + chmod 644 "$SESSIONS/-main repo/repo.jsonl" +fi + +# Without TMPDIR nothing may land in the repository. +audit no-tmpdir -u TMPDIR +[ ! -e "$REPO/.pstack-scratch" ] || fail "scratch written inside the repo: $REPO/.pstack-scratch" +[ -z "$(git -C "$REPO" status --porcelain --ignored)" ] || fail "the audit left files in the repo" + +if [ "$FAILS" -gt 0 ]; then + printf 'worktree audit: FAIL (%d)\n' "$FAILS" + exit 1 +fi +printf 'worktree audit: PASS\n' diff --git a/plugins/pstack/skills/omp-mechanics/references/session-store-on-omp.md b/plugins/pstack/skills/omp-mechanics/references/session-store-on-omp.md index 340dd94..b1c82a7 100644 --- a/plugins/pstack/skills/omp-mechanics/references/session-store-on-omp.md +++ b/plugins/pstack/skills/omp-mechanics/references/session-store-on-omp.md @@ -6,20 +6,31 @@ before trusting this file after an upgrade. ## The root -Resolved, not fixed. The default is `~/.omp/agent/sessions/`. `PI_CODING_AGENT_SESSION_DIR` and an -explicit session-directory argument win, then `PI_CODING_AGENT_DIR` for the default profile, then -the profile root under `PI_CONFIG_DIR`, then `XDG_DATA_HOME`, which redirects only when its `omp` -root already exists. Resolve it once instead of hardcoding it. +Resolved, not fixed. The default is `~/.omp/agent/sessions/`, one bucket per cwd under it. Resolve +it once instead of hardcoding it. + +- `PI_CODING_AGENT_SESSION_DIR` or an explicit `--session-dir` wins, and that directory holds the + transcripts themselves, `/*.jsonl`, with no cwd bucket under it. Never append a bucket to it. +- Otherwise a profile, `OMP_PROFILE` and then `PI_PROFILE`, moves the root to + `~/${PI_CONFIG_DIR:-.omp}/profiles//agent/sessions/` and `PI_CODING_AGENT_DIR` is ignored. + With no profile, `PI_CODING_AGENT_DIR` replaces the agent directory and `sessions/` sits under it. +- While the agent directory is the default one, an existing `$XDG_DATA_HOME/omp`, or + `$XDG_DATA_HOME/omp/profiles/` under a profile, replaces it as the data root, and the + sessions sit directly under that, `$XDG_DATA_HOME/omp/sessions/`, with no `agent/` segment. ## The bucket name -One bucket per canonicalized cwd, so two symlinked spellings of one directory share a bucket. +One bucket per canonicalized cwd, so two symlinked spellings of one directory share a bucket. The +home and temp roots are canonicalized too, and `/`, `\`, and `:` all become `-` in every form. -- A cwd under `$HOME` becomes the home-relative path with each `/` rewritten to `-`. The leading - separator survives as a leading hyphen, so `/home/you/Desktop/proj` is `-Desktop-proj` and +- A cwd under the temp root, `$TMPDIR`, then `$TMP`, `$TEMP`, `/tmp`, becomes `-tmp-`, the + root itself `-tmp`. The temp root is checked first, so a temp directory inside `$HOME` still + yields `-tmp-`. +- A cwd under `$HOME` becomes the home-relative path with each separator rewritten to `-`. The + leading separator survives as a leading hyphen, so `/home/you/Desktop/proj` is `-Desktop-proj` and `/home/you/.omp/pstack` is `-.omp-pstack`. -- A cwd under the temp root becomes `-tmp-`. -- Anything else becomes `----`. +- Anything else drops its leading `/` and becomes `----`, so `/srv/a:b` is + `--srv-a-b--`. Two distinct paths that encode identically share one bucket unless the live runtime separates them. Probe the current store after an upgrade. Older stores may contain a short-lived hashed form, diff --git a/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh index 670ddf8..bf1fccf 100755 --- a/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Read-only worktree prune audit. Classifies every git worktree by size, merge -# state, uncommitted work, and remote/PR state. Emits a table sorted by size -# with a suggested bucket. Never deletes anything; deletion stays a -# human-gated step in the playbook. +# state, uncommitted work, remote/PR state, and the most recent chat that +# operated in it. Emits a table sorted by size with a suggested bucket. Never +# deletes anything; deletion stays a human-gated step in the playbook. # # Usage: worktree-audit.sh [-b|--base ] [repo-path] # repo-path defaults to the current repo. @@ -52,51 +52,89 @@ origin/*) git fetch origin "+refs/heads/${base#origin/}:refs/remotes/$base" --qu esac git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 || { echo "error: base ref not found: $base" >&2; exit 1; } -# PR state by branch, fetched once as [{number, state, head}] with a lowercase -# state. Origin when its CLI can read this repo's PRs, otherwise gh. Empty when -# neither can. -scratch_dir="${TMPDIR:-$repo/.pstack-scratch}/worktree-audit" -mkdir -p "$scratch_dir" -prs=$(mktemp "$scratch_dir/prs.XXXXXX") -trap 'rm -f "$prs"' EXIT INT TERM +# PR state by branch, fetched once as [{number, state, head, head_oid}] with a +# lowercase state. Origin when its CLI can read this repo's PRs, otherwise gh. +# Empty when neither can. Held in memory, so the audit writes no scratch file. +prs="" if command -v origin >/dev/null 2>&1 \ && raw=$(origin pr list --author "@me" --state all --limit 1000 \ --json number,status,headRef 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef}]' > "$prs" 2>/dev/null + prs=$(printf '%s' "$raw" | jq -c '[.[] | {number, state: (.status // "" | ascii_downcase), head: .headRef, head_oid: ""}]' 2>/dev/null) elif raw=$(gh pr list --author "@me" --state all --limit 1000 \ - --json number,state,headRefName 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName}]' > "$prs" 2>/dev/null + --json number,state,headRefName,headRefOid 2>/dev/null); then + prs=$(printf '%s' "$raw" | jq -c '[.[] | {number, state: (.state // "" | ascii_downcase), head: .headRefName, head_oid: (.headRefOid // "")}]' 2>/dev/null) fi -[ -s "$prs" ] || echo "[]" > "$prs" - -# omp keys each session tree by cwd. Scan this worktree's bucket and the repo's; walking every -# bucket would scale with total session history. -sessions="${PI_CODING_AGENT_DIR:-$HOME/.omp/agent}/sessions" -bucket() { printf '%s/%s' "$sessions" "$(printf '%s' "${1#"$HOME"}" | sed 's#/#-#g')"; } -repo_bucket=$(bucket "$main_wt") -[ -d "$repo_bucket" ] || repo_bucket="$sessions" +[ -n "$prs" ] || prs="[]" + +# omp's session store, resolved the way omp resolves it. An explicit +# PI_CODING_AGENT_SESSION_DIR is the transcript directory itself, with no cwd +# bucket under it. Otherwise the root is the profile's (OMP_PROFILE, else +# PI_PROFILE) under ~/${PI_CONFIG_DIR:-.omp}, PI_CODING_AGENT_DIR when no +# profile is set, or an existing XDG_DATA_HOME/omp when the agent dir is the +# default, and it holds one bucket per canonical cwd. Only the buckets of this +# repo's worktrees are scanned; walking every bucket would scale with total +# session history. A store that cannot be read makes LAST_CHAT "?" and holds +# the row at verify-recent-chat instead of reading as no chat at all. +canon() { (CDPATH='' cd -- "$1" 2>/dev/null && pwd -P) || printf '%s\n' "$1"; } +dash() { printf '%s' "$1" | tr '/\\:' '---'; } +# Path of $2 relative to $1, failing when $2 is outside it. Like omp, a +# relative path starting with ".." counts as outside. +rel_under() { + local root="${1%/}" + case "$2" in + "$root") return 0 ;; + "$root"/*) case "${2#"$root"/}" in ..*) return 1 ;; esac; printf '%s' "${2#"$root"/}" ;; + *) return 1 ;; + esac +} +# omp's bucket name for a canonical cwd. The temp root is checked before home. +bucket_name() { + local rel + if rel=$(rel_under "$temp_root" "$1"); then printf '%s' "-tmp${rel:+-$(dash "$rel")}" + elif rel=$(rel_under "$home_root" "$1"); then printf '%s' "-$(dash "$rel")" + else printf '%s' "--$(dash "${1#/}")--"; fi +} +session_dirs=() +if [ -n "${PI_CODING_AGENT_SESSION_DIR:-}" ]; then + sessions=$PI_CODING_AGENT_SESSION_DIR + session_dirs=("$sessions/") +else + profile=$(printf '%s' "${OMP_PROFILE-${PI_PROFILE-}}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + [ "$profile" = default ] && profile="" + config="$HOME/${PI_CONFIG_DIR:-.omp}${profile:+/profiles/$profile}" + agent="$config/agent" + [ -z "$profile" ] && [ -n "${PI_CODING_AGENT_DIR:-}" ] && agent=$PI_CODING_AGENT_DIR + data=$agent + xdg="${XDG_DATA_HOME:-}/omp${profile:+/profiles/$profile}" + [ "$agent" = "$config/agent" ] && [ -n "${XDG_DATA_HOME:-}" ] && [ -e "$xdg" ] && data=$xdg + sessions="$data/sessions" + home_root=$(canon "$HOME") + temp_root=$(canon "${TMPDIR:-${TMP:-${TEMP:-/tmp}}}") + while IFS= read -r path; do + dir="$sessions/$(bucket_name "$(canon "$path")")" + [ -d "$dir" ] && session_dirs+=("$dir/") + done < <(printf '%s\n' "$worktree_records" | awk '/^worktree /{sub(/^worktree /, ""); print}') +fi +chat_lookup=ok +if [ ! -d "$sessions" ]; then + chat_lookup=failed + echo "warn: no omp session store at $sessions; LAST_CHAT is ? and rows hold at verify-recent-chat" >&2 +fi +# GNU and BSD spell file mtime and epoch formatting differently; probe once. +if stat -c %Y / >/dev/null 2>&1; then mtime() { stat -c %Y "$1"; }; else mtime() { stat -f %m "$1"; }; fi +if date -d @0 +%s >/dev/null 2>&1; then day() { date -d "@$1" '+%Y-%m-%d'; }; else day() { date -r "$1" '+%Y-%m-%d'; }; fi now=$(date +%s) echo "# base: $base" printf "SIZE\tAGE\tMERGED\tDIRTY\tLOCKED\tINSPECT\tREMOTE\tPR\tLAST_CHAT\tBUCKET\tWORKTREE\n" -printf '%s\n' "$worktree_records" | awk '/^worktree /{sub(/^worktree /, ""); print}' | while IFS= read -r wt; do - - inspect=ok - lock=$(printf '%s\n' "$worktree_records" | awk -v target="$wt" ' - BEGIN { RS=""; FS="\n" } - $1 == "worktree " target { - for (i = 1; i <= NF; i++) { - if ($i ~ /^locked/) { - reason = substr($i, 8) - print reason == "" ? "yes" : reason - exit - } - } - }') - [ -z "$lock" ] && lock="-" +printf '%s\n' "$worktree_records" | awk ' + /^worktree / { if (wt != "") print wt "\t" lock; wt = substr($0, 10); lock = "-" } + /^locked/ { lock = substr($0, 8); if (lock == "") lock = "yes" } + END { if (wt != "") print wt "\t" lock }' | while IFS=$'\t' read -r wt lock; do [ "$wt" = "$main_wt" ] && continue + inspect=ok size=$(du -sh "$wt" 2>/dev/null | awk '{print $1}') if ! head=$(git -C "$wt" rev-parse HEAD 2>/dev/null); then head="" @@ -109,14 +147,12 @@ printf '%s\n' "$worktree_records" | awk '/^worktree /{sub(/^worktree /, ""); pri # real signal; merge-base only catches fast-forward/rebase merges. git merge-base --is-ancestor "$head" "$base" 2>/dev/null && merged=YES || merged=no - # Distinguish real WIP (tracked edits) from disposable untracked scratch. - if ! porcelain=$(git -C "$wt" status --porcelain 2>/dev/null); then + # Treat tracked, untracked, and ignored paths as work that must be held. + if ! porcelain=$(git -C "$wt" status --porcelain --untracked-files=all --ignored=matching 2>/dev/null); then dirty=error inspect="status-error" elif [ -z "$porcelain" ]; then dirty=clean - elif printf '%s\n' "$porcelain" | grep -qv '^??'; then - dirty="wip:$(printf '%s\n' "$porcelain" | grep -cv '^??')" - else dirty="scratch:$(printf '%s\n' "$porcelain" | grep -c '^??')"; fi + else dirty="wip:$(printf '%s\n' "$porcelain" | wc -l | tr -d '[:space:]')"; fi branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") if [ -z "$branch" ]; then remote=detached @@ -124,42 +160,56 @@ printf '%s\n' "$worktree_records" | awk '/^worktree /{sub(/^worktree /, ""); pri [ "$(git -C "$wt" rev-parse "origin/$branch" 2>/dev/null)" = "$head" ] \ && remote=pushed \ || remote="ahead$(git -C "$wt" rev-list --count "origin/$branch..HEAD" 2>/dev/null)" + [ "$remote" = ahead0 ] && remote=behind else remote=no-remote; fi - pr=$([ -n "$branch" ] && jq -r --arg b "$branch" \ - '.[] | select(.head==$b) | "#\(.number)/\(.state)"' "$prs" 2>/dev/null | head -1) + # Several PRs can share a branch name; prefer the one whose head is HEAD. + pr_record=$([ -n "$branch" ] && printf '%s' "$prs" | jq -r --arg b "$branch" --arg h "$head" \ + '[.[] | select(.head==$b)] | (map(select(.head_oid==$h)) + .)[0] + | select(.) | "#\(.number)/\(.state)\t\(.head_oid // "")"' 2>/dev/null) + pr=${pr_record%%$'\t'*} + pr_head=${pr_record#*$'\t'} [ -z "$pr" ] && pr="-" - # Most recent chat whose transcript operated in this worktree. Match path - # followed by "/" or a quote so glint-482 does not match glint-482-r37. - last="-"; last_ts=0 - wt_bucket=$(bucket "$wt"); [ -d "$wt_bucket" ] || wt_bucket="$repo_bucket" - if [ -d "$repo_bucket" ]; then - f=$(rg -l -e "${wt}/" -e "${wt}\"" "$repo_bucket" "$wt_bucket" 2>/dev/null \ - | xargs -r stat -c '%Y %n' 2>/dev/null | sort -rn | head -1) - if [ -n "$f" ]; then last_ts=$(echo "$f" | awk '{print $1}') - last=$(date -d @"$last_ts" '+%Y-%m-%d' 2>/dev/null); fi + # Most recent chat whose transcript operated in this worktree, under the + # path git lists or its canonical spelling. Match path followed by "/" or a + # quote so glint-482 does not match glint-482-r37. + last="-"; last_ts=0; chat=$chat_lookup + if [ "$chat" = ok ] && [ "${#session_dirs[@]}" -gt 0 ]; then + cwt=$(canon "$wt") + matched=$(grep -RlsF --include='*.jsonl' -e "${wt}/" -e "${wt}\"" -e "${cwt}/" -e "${cwt}\"" \ + -- "${session_dirs[@]}") + [ $? -le 1 ] || chat=failed + while IFS= read -r f; do + [ -n "$f" ] || continue + ts=$(mtime "$f") || { chat=failed; break; } + [ "$ts" -gt "$last_ts" ] && last_ts=$ts + done <<<"$matched" fi + if [ "$chat" != ok ]; then last="?" + elif [ "$last_ts" -gt 0 ]; then last=$(day "$last_ts" 2>/dev/null) || last="?"; fi recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no) - if [ "$inspect" != ok ]; then bucket=hold-error - elif [ "$lock" != "-" ]; then bucket=hold-locked - else - case "$dirty" in - error) bucket=hold-error ;; - wip:*) bucket=hold-wip ;; - *) - case "$pr" in - *OPEN*) bucket=hold-open-pr ;; - *) - if [ "$recent" = yes ]; then bucket=verify-recent-chat - elif [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe - else bucket=review; fi ;; - esac ;; - esac - fi + # An exact PR head proves the commits were pushed even if origin has no + # branch (deleted after merging, or hosted on a fork). A closed PR still + # proves nothing about whether those commits were merged. + case "$pr" in */merged) pr_merged=yes ;; *) pr_merged=no ;; esac + case "$remote" in ahead*|no-remote|detached) unpushed=yes ;; *) unpushed=no ;; esac + [ "$merged" = YES ] && unpushed=no + [ -n "$pr_head" ] && [ "$head" = "$pr_head" ] && unpushed=no + + case "$dirty" in wip:*) bucket=hold-wip ;; *) + case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) + if [ "$unpushed" = yes ]; then bucket=hold-unpushed + elif [ "$recent" = yes ] || [ "$chat" != ok ]; then bucket=verify-recent-chat + elif [ "$merged" = YES ] || [ "$pr_merged" = yes ]; then bucket=safe + else bucket=review; fi ;; + esac ;; + esac + # A locked or unreadable worktree is never a candidate, whatever else holds. + [ "$lock" != "-" ] && bucket=hold-locked + [ "$inspect" != ok ] && bucket=hold-error - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n" \ - "$size" "$age" "$merged" "$dirty" "$remote" "$pr" "$last" "$bucket" "$wt" + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n" \ + "$size" "$age" "$merged" "$dirty" "$lock" "$inspect" "$remote" "$pr" "$last" "$bucket" "$wt" done | sort -t$'\t' -k1,1 -rh - From 8148e2bd4a14ed041a7b79e6898f698f481d40a1 Mon Sep 17 00:00:00 2001 From: Negrescu Alexandru Date: Fri, 23 Oct 2026 00:00:40 +0300 Subject: [PATCH 08/10] slice d --- omp-port/patches/zzz-watch-pr-fixes.patch | 607 ++++++++++++++++++ omp-port/patches/zzzz-check-plan-fix.patch | 38 ++ .../patches/zzzz-orch-lock-takeover.patch | 312 +++++++++ .../skills/poteto-mode/scripts/check-plan.mjs | 2 +- .../poteto-mode/scripts/check-plan.test.ts | 14 + .../poteto-mode/scripts/orch/orch.test.ts | 138 +++- .../skills/poteto-mode/scripts/orch/store.ts | 140 +++- .../poteto-mode/scripts/watch-pr/cli.test.ts | 75 +++ .../poteto-mode/scripts/watch-pr/cli.ts | 6 +- .../scripts/watch-pr/github.test.ts | 62 ++ .../poteto-mode/scripts/watch-pr/github.ts | 12 +- .../scripts/watch-pr/policy.test.ts | 110 ++++ .../poteto-mode/scripts/watch-pr/policy.ts | 47 +- .../poteto-mode/scripts/watch-pr/render.ts | 2 + .../poteto-mode/scripts/watch-pr/types.ts | 13 +- 15 files changed, 1554 insertions(+), 24 deletions(-) create mode 100644 omp-port/patches/zzz-watch-pr-fixes.patch create mode 100644 omp-port/patches/zzzz-check-plan-fix.patch create mode 100644 omp-port/patches/zzzz-orch-lock-takeover.patch diff --git a/omp-port/patches/zzz-watch-pr-fixes.patch b/omp-port/patches/zzz-watch-pr-fixes.patch new file mode 100644 index 0000000..2334cbe --- /dev/null +++ b/omp-port/patches/zzz-watch-pr-fixes.patch @@ -0,0 +1,607 @@ +# Open upstream fixes to watch-pr and the orch store, carried until cursor/plugins merges them. +# Each hunk is the upstream PR's own code and test, rebased onto the post-rules tree unchanged. +# +# - #746, rollup of #664: stack discovery requests isCrossRepository and never treats a fork's +# head as a parent or child, so a fork's open main->main PR no longer spins the parent walk +# - #746, rollup of #666: polling sleeps, interval and query backoff alike, are capped at the +# --timeout deadline instead of overshooting it +# - #746, rollup of #684: a branch with a closed or merged PR plus one open replacement resolves +# to the open one instead of failing with "multiple pull requests" +# - #769: --status-only reports a head with no checks as ci-none in one table and stops on the +# first query error, instead of retrying through the backoff schedule +# +# #769 and the still-open #592 both add a field after `allowDraft` in policy.ts, so if upstream +# lands #592 first this patch stops applying there and needs one line re-resolved. +# +diff --git a/skills/poteto-mode/scripts/orch/orch.test.ts b/skills/poteto-mode/scripts/orch/orch.test.ts +--- a/skills/poteto-mode/scripts/orch/orch.test.ts ++++ b/skills/poteto-mode/scripts/orch/orch.test.ts +@@ -573,6 +573,40 @@ describe("Store", () => { + }); + }); + ++ it(`resolves a replacement ${forge} PR alongside closed branch history`, async () => { ++ for (const historicalState of ["closed", "merged"] as const) { ++ const { directory, store } = await initializedStore(); ++ const stack = await makeGitStack(directory); ++ await withFakeForge({ ++ directory, forge, ++ currentRows: forgeRows(forge, [ ++ { number: 9, state: historicalState, head: "stack/open", base: "main" }, ++ { number: 11, state: "open", head: "stack/open", base: "stack/closed" }, ++ ]), ++ operation: async () => { ++ const frontier = await store.frontier.set({ repo: stack.repo, forge }); ++ expect(frontier.prs.map((row) => row.pr)).toEqual([10, 13, 11, 14]); ++ expect(frontier.lowestUnmerged).toBe(11); ++ }, ++ }); ++ } ++ }); ++ ++ it(`still rejects multiple active ${forge} PRs on one branch`, async () => { ++ const { directory, store } = await initializedStore(); ++ const stack = await makeGitStack(directory); ++ await withFakeForge({ ++ directory, forge, ++ currentRows: forgeRows(forge, [ ++ { number: 11, state: "open", head: "stack/open", base: "stack/closed" }, ++ { number: 12, state: "open", head: "stack/open", base: "main" }, ++ ]), ++ operation: async () => { ++ await expect(store.frontier.set({ repo: stack.repo, forge })).rejects.toThrow("multiple pull requests"); ++ }, ++ }); ++ }); ++ + it(`rejects unparseable ${forge} output loudly`, async () => { + const { directory, store } = await initializedStore(); + const stack = await makeGitStack(directory); +diff --git a/skills/poteto-mode/scripts/orch/store.ts b/skills/poteto-mode/scripts/orch/store.ts +--- a/skills/poteto-mode/scripts/orch/store.ts ++++ b/skills/poteto-mode/scripts/orch/store.ts +@@ -1113,15 +1113,19 @@ function branchPullRequest({ + repo: string; + }): ForgePullRequest { + const rows = listPullRequests({ filter: "--head", branch, forge, repo }); +- const row = rows[0]; ++ // A branch may have closed or merged historical PRs after stack surgery. ++ // Prefer its sole active PR, but retain historical anchors when none is open. ++ const active = rows.filter((row) => row.state === "OPEN"); ++ const candidates = active.length > 0 ? active : rows; ++ const row = candidates[0]; + if (row === undefined) { + throw new UserError( + `branch ${branch} has no pull request; open one with ${forge} pr create, or resolve the frontier from a branch whose chain is fully submitted` + ); + } +- if (rows.length > 1) { ++ if (candidates.length > 1) { + throw new UserError( +- `branch ${branch} has multiple pull requests (${rows.map((item) => `#${item.pr}`).join(", ")}); close the stale ones first` ++ `branch ${branch} has multiple pull requests (${candidates.map((item) => `#${item.pr}`).join(", ")}); close the stale ones first` + ); + } + return row; +diff --git a/skills/poteto-mode/scripts/watch-pr/cli.test.ts b/skills/poteto-mode/scripts/watch-pr/cli.test.ts +--- a/skills/poteto-mode/scripts/watch-pr/cli.test.ts ++++ b/skills/poteto-mode/scripts/watch-pr/cli.test.ts +@@ -1,6 +1,7 @@ + import { describe, expect, it } from "bun:test"; + import { type CliRuntime, main, parseArgs } from "./cli.ts"; + import { fakeReader, passingCheck } from "./fakes.test-helper.ts"; ++import { WatcherQueryError } from "./github.ts"; + import { renderJson, renderPretty } from "./render.ts"; + import type { GitHubReader, WatcherVerdict } from "./types.ts"; + import { parsePrNumber } from "./types.ts"; +@@ -103,6 +104,80 @@ describe("parseArgs", () => { + }); + }); + ++describe("heads without checks", () => { ++ const noChecks = () => ++ fakeReader({ ++ fastPath: { ++ kind: "unusable", ++ exitCode: 1, ++ stderr: "no checks reported on the 'feature' branch", ++ }, ++ rollupPages: [{ checks: [], endCursor: null }], ++ }); ++ ++ it("--status-only reports the head once instead of retrying", async () => { ++ const harness = testRuntime(noChecks()); ++ expect(await main(["--pr", "1", "--status-only"], harness.runtime)).toBe(0); ++ expect(harness.stdout).toHaveLength(1); ++ const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; ++ expect(verdict.kind).toBe("STATUS"); ++ if (verdict.kind !== "STATUS") throw new Error("expected STATUS"); ++ const row = verdict.rows[0]; ++ expect(row.kind).toBe("open"); ++ if (row.kind !== "open") throw new Error("expected open row"); ++ expect(row.ci.kind).toBe("ci-none"); ++ }); ++ ++ it("--status-only --pretty names the missing checks", async () => { ++ const harness = testRuntime(noChecks()); ++ expect( ++ await main(["--pr", "1", "--status-only", "--pretty"], harness.runtime) ++ ).toBe(0); ++ expect(harness.stdout.join("")).toContain("no checks reported"); ++ }); ++ ++ it("--status-only ends on the first query error instead of sleeping", async () => { ++ const reader = noChecks(); ++ const failing: GitHubReader = { ++ ...reader, ++ async pullRequest() { ++ throw new WatcherQueryError({ ++ kind: "json-parse", ++ retryable: true, ++ detail: "simulated outage", ++ }); ++ }, ++ }; ++ const harness = testRuntime(failing); ++ const code = await main(["--pr", "1", "--status-only"], harness.runtime); ++ expect(code).not.toBe(0); ++ expect(harness.stdout).toHaveLength(1); ++ const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; ++ expect(verdict.kind).not.toBe("RETRY"); ++ }); ++ ++ it("polling still treats a head without checks as a retryable error", async () => { ++ const harness = testRuntime(noChecks()); ++ const slept: number[] = []; ++ const runtime: CliRuntime = { ++ ...harness.runtime, ++ clock: { ++ ...harness.runtime.clock, ++ async sleep(seconds) { ++ slept.push(seconds); ++ if (slept.length >= 2) throw new Error("stop test"); ++ }, ++ }, ++ }; ++ await expect( ++ main(["--pr", "1", "--max-query-errors", "5"], runtime) ++ ).rejects.toThrow("stop test"); ++ expect(slept.length).toBe(2); ++ const first = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; ++ expect(first.kind).toBe("RETRY"); ++ }); ++}); ++ + describe("rendering", () => { + const context = { + owner: "owner", +diff --git a/skills/poteto-mode/scripts/watch-pr/cli.ts b/skills/poteto-mode/scripts/watch-pr/cli.ts +--- a/skills/poteto-mode/scripts/watch-pr/cli.ts ++++ b/skills/poteto-mode/scripts/watch-pr/cli.ts +@@ -126,7 +126,11 @@ export function parseArgs( + positiveInteger, + 5 + ) +- .option("--status-only", "print one status table and exit 0", false) ++ .option( ++ "--status-only", ++ "read GitHub once, print one status table and exit 0; a head with no checks is reported, not retried", ++ false ++ ) + .option("--allow-draft", "do not treat a draft as a merge gate", false) + .option("--pretty", "render human text instead of JSON", false); + program.parse(argv, { from: "user" }); +diff --git a/skills/poteto-mode/scripts/watch-pr/github.test.ts b/skills/poteto-mode/scripts/watch-pr/github.test.ts +--- a/skills/poteto-mode/scripts/watch-pr/github.test.ts ++++ b/skills/poteto-mode/scripts/watch-pr/github.test.ts +@@ -1,4 +1,8 @@ + import { describe, expect, it } from "bun:test"; ++import { spawnSync } from "node:child_process"; ++import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; ++import { tmpdir } from "node:os"; ++import { join } from "node:path"; + import { + ChecksUnavailable, + WatcherQueryError, +@@ -418,19 +422,77 @@ describe("context and stack discovery", () => { + { + number: parsePrNumber(41), + headRefName: "base-feature", ++ isCrossRepository: false, + baseRefName: "main", + }, + { + number: context.number, + headRefName: "feature", ++ isCrossRepository: false, + baseRefName: "base-feature", + }, + { + number: parsePrNumber(43), + headRefName: "upstack", ++ isCrossRepository: false, + baseRefName: "feature", + }, + ]); + expect(ordered.map((item) => Number(item.number))).toEqual([41, 42, 43]); + }); ++ it("does not use a fork's same-named head as a stack parent", () => { ++ const ordered = orderStack(context, [ ++ { number: parsePrNumber(41), headRefName: "base-feature", baseRefName: "main", isCrossRepository: true }, ++ { number: context.number, headRefName: "feature", baseRefName: "base-feature", isCrossRepository: false }, ++ ]); ++ expect(ordered.map((item) => Number(item.number))).toEqual([42]); ++ }); ++ ++ it("does not attach upstream branch children to a fork head", () => { ++ const ordered = orderStack(context, [ ++ { number: context.number, headRefName: "feature", baseRefName: "main", isCrossRepository: true }, ++ { number: parsePrNumber(43), headRefName: "upstack", baseRefName: "feature", isCrossRepository: false }, ++ ]); ++ expect(ordered.map((item) => Number(item.number))).toEqual([42]); ++ }); ++ ++ it("terminates when an unrelated fork opens main against main", () => { ++ // Bound the subprocess so this regression reports a failure instead of hanging the suite. ++ const program = `import { orderStack } from ${JSON.stringify(import.meta.dir + "/github.ts")}; ++ console.log(JSON.stringify(orderStack(${JSON.stringify(context)}, [ ++ { number: 41, headRefName: "main", baseRefName: "main", isCrossRepository: true }, ++ { number: 42, headRefName: "feature", baseRefName: "main", isCrossRepository: false } ++ ])));`; ++ const child = spawnSync(process.execPath, ["-e", program], { encoding: "utf8", timeout: 2000 }); ++ expect(child.error).toBeUndefined(); ++ expect(child.status).toBe(0); ++ expect(JSON.parse(child.stdout).map((item: { number: number }) => item.number)).toEqual([42]); ++ }); ++ ++ it("requests and parses fork identity from gh before stack discovery", () => { ++ const directory = mkdtempSync(join(tmpdir(), "watch-pr-fork-")); ++ try { ++ writeFileSync(join(directory, "gh"), `#!/bin/sh ++case "$*" in ++ *number,headRefName,baseRefName,isCrossRepository*) ;; ++ *) exit 2 ;; ++esac ++printf '%s\\n' '[{"number":42,"headRefName":"feature","baseRefName":"main","isCrossRepository":true}]' ++`, { mode: 0o755 }); ++ const program = `import { GhGitHubReader } from ${JSON.stringify(import.meta.dir + "/github.ts")}; ++ console.log(JSON.stringify(await new GhGitHubReader().openPullRequests({ owner: "owner", repo: "repo" })));`; ++ const child = spawnSync(process.execPath, ["-e", program], { ++ encoding: "utf8", timeout: 2000, ++ env: { ...process.env, PATH: directory + ":" + process.env.PATH }, ++ }); ++ expect(child.error).toBeUndefined(); ++ expect(child.status).toBe(0); ++ expect(JSON.parse(child.stdout)).toEqual([ ++ { number: 42, headRefName: "feature", baseRefName: "main", isCrossRepository: true }, ++ ]); ++ } finally { ++ rmSync(directory, { recursive: true, force: true }); ++ } ++ }); ++ + }); +diff --git a/skills/poteto-mode/scripts/watch-pr/github.ts b/skills/poteto-mode/scripts/watch-pr/github.ts +--- a/skills/poteto-mode/scripts/watch-pr/github.ts ++++ b/skills/poteto-mode/scripts/watch-pr/github.ts +@@ -500,11 +500,14 @@ export class GhGitHubReader implements T.GitHubReader { + "--limit", + "300", + "--json", +- "number,headRefName,baseRefName", ++ "number,headRefName,baseRefName,isCrossRepository", + ]); + return list(value, "open PRs").map((item, index) => { + const object = record(item, `open PRs[${index}]`); ++ if (typeof object.isCrossRepository !== "boolean") ++ missing(`open PRs[${index}].isCrossRepository`, object.isCrossRepository); + return { ++ isCrossRepository: object.isCrossRepository, + number: parsePrNumber(object.number, `open PRs[${index}].number`), + headRefName: string( + object.headRefName, +@@ -671,7 +674,11 @@ export function orderStack( + open: readonly T.OpenPullRequest[] + ): T.NonEmpty { + const byNumber = new Map(open.map((pr) => [pr.number, pr])); +- const byHead = new Map(open.map((pr) => [pr.headRefName, pr])); ++ // A base ref belongs to this repository. A fork head with the same name ++ // cannot be its parent, even when that name is the default branch. ++ const byHead = new Map( ++ open.filter((pr) => !pr.isCrossRepository).map((pr) => [pr.headRefName, pr]) ++ ); + const children = new Map(); + for (const pr of open) + children.set(pr.baseRefName, [...(children.get(pr.baseRefName) ?? []), pr]); +@@ -693,6 +700,7 @@ export function orderStack( + ]); + const up: T.OpenPullRequest[] = []; + const visit = (parent: T.OpenPullRequest): void => { ++ if (parent.isCrossRepository) return; + for (const child of children.get(parent.headRefName) ?? []) { + if (seen.has(child.number)) continue; + seen.add(child.number); +diff --git a/skills/poteto-mode/scripts/watch-pr/policy.test.ts b/skills/poteto-mode/scripts/watch-pr/policy.test.ts +--- a/skills/poteto-mode/scripts/watch-pr/policy.test.ts ++++ b/skills/poteto-mode/scripts/watch-pr/policy.test.ts +@@ -10,6 +10,7 @@ import { + queryBackoffSeconds, + readSnapshot, + runQueued, ++ runSimple, + selectTierMajorStackDecision, + } from "./policy.ts"; + import { +@@ -91,6 +92,54 @@ describe("readiness truth table", () => { + }); + + describe("snapshot query planning", () => { ++ it("reports a head without checks as ci-none when asked to", async () => { ++ const reader = fakeReader({ ++ fastPath: { ++ kind: "unusable", ++ exitCode: 1, ++ stderr: "no checks reported on the 'feature' branch", ++ }, ++ rollupPages: [{ checks: [], endCursor: null }], ++ }); ++ const snapshot = await readSnapshot({ ++ reader, ++ context: context(7), ++ pendingHistory: "include", ++ allowDraft: false, ++ noChecks: "report", ++ }); ++ expect(snapshot.kind).toBe("open"); ++ if (snapshot.kind !== "open") throw new Error("expected open snapshot"); ++ expect(snapshot.ci.kind).toBe("ci-none"); ++ expect(snapshot.ci.all).toEqual([]); ++ expect(snapshot.reviewAutomationRunning).toBe(false); ++ if (snapshot.ci.kind !== "ci-none") throw new Error("expected ci-none"); ++ expect(snapshot.ci.github.kind).toBe("allowed"); ++ expect(reader.calls).toContain("commitRollups"); ++ }); ++ ++ it("keeps a head without checks as a retryable query error by default", async () => { ++ const reader = fakeReader({ ++ fastPath: { kind: "checks", checks: [] }, ++ rollupPages: [{ checks: [], endCursor: null }], ++ }); ++ let caught: unknown = null; ++ try { ++ await readSnapshot({ ++ reader, ++ context: context(8), ++ pendingHistory: "include", ++ allowDraft: false, ++ }); ++ } catch (error) { ++ caught = error; ++ } ++ expect(caught).toBeInstanceOf(WatcherQueryError); ++ if (!(caught instanceof WatcherQueryError)) throw new Error("expected query error"); ++ expect(caught.failure.kind).toBe("checks-unavailable"); ++ expect(caught.failure.retryable).toBe(true); ++ }); ++ + it("does not query commit rollups while queued checks are pending", async () => { + const reader = fakeReader({ + fastPath: { kind: "checks", checks: [pendingCheck()] }, +@@ -418,3 +467,64 @@ it("uses the specified retry floor and cap", () => { + expect(queryBackoffSeconds(1, 2)).toBe(120); + expect(queryBackoffSeconds(60, 4)).toBe(300); + }); ++ ++ ++describe("polling deadline sleeps", () => { ++ for (const mode of ["single", "queued-stack", "query-retry"] as const) { ++ it(`caps ${mode} sleeps at the configured deadline`, async () => { ++ let now = 0; ++ const sleeps: number[] = []; ++ const pending = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] } }); ++ const reader = mode === "query-retry" ++ ? { ...pending, async pullRequest(): Promise { ++ throw new WatcherQueryError({ kind: "command-exit", retryable: true, code: 1, detail: "temporary failure" }); ++ } } ++ : pending; ++ const dependencies = { ++ reader, ++ clock: { ++ now: () => now, ++ observedAt: () => "2026-07-26T00:00:00.000Z", ++ async sleep(seconds: number) { sleeps.push(seconds); now += seconds; }, ++ }, ++ emit() {}, ++ }; ++ const polling = { ...options, interval: 60, timeout: 5 }; ++ const result = mode === "queued-stack" ++ ? await runQueued({ dependencies, contexts: [context(1)], options: polling }) ++ : await runSimple({ dependencies, contexts: [context(1)], mode: "single", statusOnly: false, options: polling }); ++ expect(result.kind).toBe("TIMEOUT"); ++ expect(sleeps).toEqual([5]); ++ expect(now).toBe(5); ++ }); ++ } ++ it("preserves the polling interval when the deadline is disabled", async () => { ++ let now = 0; ++ let reads = 0; ++ const sleeps: number[] = []; ++ const pending = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] } }); ++ const passed = fakeReader({ fastPath: { kind: "checks", checks: [passingCheck()] } }); ++ const result = await runSimple({ ++ dependencies: { ++ reader: { ++ ...pending, ++ async checksFastPath(pr) { ++ reads += 1; ++ return reads === 1 ? pending.checksFastPath(pr) : passed.checksFastPath(pr); ++ }, ++ }, ++ clock: { ++ now: () => now, ++ observedAt: () => "2026-07-26T00:00:00.000Z", ++ async sleep(seconds) { sleeps.push(seconds); now += seconds; }, ++ }, ++ emit() {}, ++ }, ++ contexts: [context(1)], mode: "single", statusOnly: false, ++ options: { ...options, interval: 60, timeout: 0 }, ++ }); ++ expect(result.kind).toBe("READY"); ++ expect(sleeps).toEqual([60]); ++ }); ++ ++}); +diff --git a/skills/poteto-mode/scripts/watch-pr/policy.ts b/skills/poteto-mode/scripts/watch-pr/policy.ts +--- a/skills/poteto-mode/scripts/watch-pr/policy.ts ++++ b/skills/poteto-mode/scripts/watch-pr/policy.ts +@@ -1,4 +1,5 @@ + import { ++ ChecksUnavailable, + WatcherQueryError, + resolveChecks, + resolveReviewThreads, +@@ -61,6 +62,8 @@ export async function readSnapshot(args: { + readonly context: T.PrContext; + readonly pendingHistory: "include" | "omit"; + readonly allowDraft: boolean; ++ /** `report`: a head with no checks becomes `ci-none`. `retry` (default): it stays a retryable query error, because a just-pushed head looks the same. */ ++ readonly noChecks?: "report" | "retry"; + }): Promise { + const facts = await args.reader.pullRequest(args.context); + if (facts.state === "MERGED" || facts.mergedAt !== null) +@@ -68,7 +71,30 @@ export async function readSnapshot(args: { + if (facts.state === "CLOSED") + return { kind: "closed", context: args.context, facts }; + const threads = await resolveReviewThreads(args.reader, args.context); +- const checks = await resolveChecks(args.reader, args.context); ++ let checks: T.CheckRead; ++ try { ++ checks = await resolveChecks(args.reader, args.context); ++ } catch (error) { ++ if (!(error instanceof ChecksUnavailable) || args.noChecks !== "report") ++ throw error; ++ const merge = await mergeAssessment(args.reader, facts); ++ return { ++ kind: "open", ++ context: args.context, ++ facts, ++ threads, ++ ci: { ++ kind: "ci-none", ++ source: "none", ++ all: [], ++ failed: [], ++ pending: [], ++ hadPreviousPassingCi: merge.hadPreviousPassingCi, ++ github: merge.github, ++ }, ++ reviewAutomationRunning: false, ++ }; ++ } + const failed = nonEmpty( + checks.checks.filter( + (check): check is T.FailedCheck => check.kind === "failed" +@@ -370,6 +396,12 @@ async function pollUntilTerminal(args: { + }): Promise { + let failures = 0; + const started = args.dependencies.clock.now(); ++ const sleep = async (seconds: number): Promise => { ++ const remaining = args.options.timeout > 0 ++ ? Math.max(0, args.options.timeout - (args.dependencies.clock.now() - started)) ++ : seconds; ++ await args.dependencies.clock.sleep(Math.min(seconds, remaining)); ++ }; + while (true) { + let result: StepResult; + try { +@@ -400,7 +432,7 @@ async function pollUntilTerminal(args: { + exitCode: 5, + reason: { kind: "status-unavailable", failure: error.failure }, + }); +- await args.dependencies.clock.sleep(retryInSeconds); ++ await sleep(retryInSeconds); + continue; + } + if (result.kind === "terminal") return result.verdict; +@@ -410,7 +442,7 @@ async function pollUntilTerminal(args: { + deadlinePassed(started, args.options, args.dependencies.clock.now()) + ) + return result.onDeadline(); +- await args.dependencies.clock.sleep(result.seconds); ++ await sleep(result.seconds); + } + } + } +@@ -422,6 +454,12 @@ export async function runSimple(args: { + readonly options: T.PollingOptions; + }): Promise { + const stamp = verdictFactory(args.dependencies.clock, args.mode); ++ // `--status-only` reads GitHub once: a head without checks is reported as ++ // such instead of being retried, and a query error ends the run on the ++ // first failure instead of sleeping through the backoff schedule. ++ const options: T.PollingOptions = args.statusOnly ++ ? { ...args.options, maxQueryErrors: 1 } ++ : args.options; + const step = async (): Promise> => { + const rows: T.PrSnapshot[] = []; + for (const context of args.contexts) +@@ -431,6 +469,7 @@ export async function runSimple(args: { + context, + pendingHistory: "include", + allowDraft: args.options.allowDraft, ++ noChecks: args.statusOnly ? "report" : "retry", + }) + ); + const complete = nonEmpty(rows); +@@ -512,7 +551,7 @@ export async function runSimple(args: { + }; + return pollUntilTerminal({ + dependencies: args.dependencies, +- options: args.options, ++ options, + stamp, + step, + }); +diff --git a/skills/poteto-mode/scripts/watch-pr/render.ts b/skills/poteto-mode/scripts/watch-pr/render.ts +--- a/skills/poteto-mode/scripts/watch-pr/render.ts ++++ b/skills/poteto-mode/scripts/watch-pr/render.ts +@@ -13,6 +13,8 @@ function ciCell(row: T.PrSnapshot): string { + return `❌ ${row.ci.failed.length} failed${row.ci.pending.length ? `, ${row.ci.pending.length} pending` : ""}${was}`; + case "ci-github-rejected": + return `❌ GitHub reports failing checks${was}`; ++ case "ci-none": ++ return `no checks reported${was}`; + default: { + const exhaustive: never = row.ci; + return exhaustive; +diff --git a/skills/poteto-mode/scripts/watch-pr/types.ts b/skills/poteto-mode/scripts/watch-pr/types.ts +--- a/skills/poteto-mode/scripts/watch-pr/types.ts ++++ b/skills/poteto-mode/scripts/watch-pr/types.ts +@@ -52,6 +52,7 @@ export interface PullRequestFacts { + } + export interface OpenPullRequest { + readonly number: PrNumber; ++ readonly isCrossRepository: boolean; + readonly headRefName: string; + readonly baseRefName: string; + } +@@ -141,7 +142,17 @@ export type CiClean = CiBase & { + readonly pending: readonly []; + readonly github: GitHubMergeAllowed; + }; +-export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean; ++/** GitHub reported no checks at all for the head. Only `--status-only` produces this; polling keeps retrying, since a freshly pushed head has no checks yet either. */ ++export interface CiNone { ++ readonly kind: "ci-none"; ++ readonly source: "none"; ++ readonly all: readonly []; ++ readonly failed: readonly []; ++ readonly pending: readonly []; ++ readonly hadPreviousPassingCi: boolean; ++ readonly github: GitHubMergeAssessment; ++} ++export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean | CiNone; + export type PrSnapshot = + | { + readonly kind: "merged" | "closed"; diff --git a/omp-port/patches/zzzz-check-plan-fix.patch b/omp-port/patches/zzzz-check-plan-fix.patch new file mode 100644 index 0000000..65726da --- /dev/null +++ b/omp-port/patches/zzzz-check-plan-fix.patch @@ -0,0 +1,38 @@ +# Open upstream fix to check-plan.mjs, carried until cursor/plugins merges it: the check-plan half +# of #745 (rollup of #672). A code span opened by two or more backticks now closes only on a run of +# the same length, so prose punctuation inside ``a `b`: c`` is not reported. The worktree-audit +# half of #745 is not here; worktree-audit-omp.patch owns that script. +# +diff --git a/skills/poteto-mode/scripts/check-plan.mjs b/skills/poteto-mode/scripts/check-plan.mjs +--- a/skills/poteto-mode/scripts/check-plan.mjs ++++ b/skills/poteto-mode/scripts/check-plan.mjs +@@ -72,7 +72,7 @@ for (let i = start; i < raw.length; i++) { + lines.push({ n, text, code }); + if (code) continue; + const prose = text +- .replace(/`[^`]*`/g, "`") ++ .replace(/(? { + const result = runPlan("```markdown `invalid`\n## Example only\n```\n", 1); + expect(result.stderr).toContain('"## Example only" after Close the program is not an appendix'); + }); ++ ++test("ignores prose punctuation inside matching multi-backtick code spans", () => { ++ for (const count of [2, 3, 4]) { ++ const delimiter = "`".repeat(count); ++ runPlan(`Use ${delimiter}echo \`x\`: “quoted” — example${delimiter}.\n`, 0); ++ } ++}); ++ ++test("checks prose following a multi-backtick span and unmatched delimiters", () => { ++ const outside = runPlan("Use ``echo `x`: y``. Bad: prose.\n", 1); ++ expect(outside.stderr).toContain("mid-sentence colon"); ++ const unmatched = runPlan("Use ``unclosed: bad` here.\n", 1); ++ expect(unmatched.stderr).toContain("mid-sentence colon"); ++}); diff --git a/omp-port/patches/zzzz-orch-lock-takeover.patch b/omp-port/patches/zzzz-orch-lock-takeover.patch new file mode 100644 index 0000000..daf8a5f --- /dev/null +++ b/omp-port/patches/zzzz-orch-lock-takeover.patch @@ -0,0 +1,312 @@ +# orch stale-lock takeover, after upstream #733 (fixes #730), with the leftover-marker defect +# that PR introduces removed. +# +# Replacing a dead holder's lock was unlink-then-create, so two takers that read the same dead +# pid could both end up holding the store. #733 serialises the takeover through a `wx` marker, +# .orch.lock.takeover, but a taker that dies holding it leaves it behind and every later +# takeover then reports the dead pid forever. Here: +# - a marker older than TAKEOVER_MARKER_STALE_MS (10 s) is stepped past, not trusted. It is +# never unlinked to make room, since two takers that both judged it stale could each unlink +# it and the second would remove the first one's fresh marker; the next marker is named +# after the stale one's inode and mtime so exactly one taker can create it, and the stale +# markers are removed on release only while they are still the files judged stale +# - holding the marker, the taker re-reads the lock and replaces it only if it still names the +# holder it judged dead (or forced), so a taker that finished first keeps its lock +# - heldBy is awaited inside the try whose finally awaits, so its rejection is never unhandled +# +diff --git a/skills/poteto-mode/scripts/orch/orch.test.ts b/skills/poteto-mode/scripts/orch/orch.test.ts +--- a/skills/poteto-mode/scripts/orch/orch.test.ts ++++ b/skills/poteto-mode/scripts/orch/orch.test.ts +@@ -6,9 +6,13 @@ import { + readFile, + readdir, + rm, ++ stat, ++ unlink, ++ utimes, + writeFile, + } from "node:fs/promises"; +-import { realpathSync } from "node:fs"; ++import { randomUUID } from "node:crypto"; ++import { realpathSync, writeFileSync } from "node:fs"; + import { tmpdir } from "node:os"; + import { join } from "node:path"; + import { +@@ -436,6 +440,104 @@ describe("Store", () => { + expect(await readdir(directory)).not.toContain(".orch.lock"); + }); + ++ it("lets exactly one of two concurrent takers replace a stale lock", async () => { ++ const { directory } = await initializedStore(); ++ const exited = Bun.spawn(["true"]); ++ await exited.exited; ++ await writeFile(join(directory, ".orch.lock"), `${exited.pid}\n`); ++ ++ // Both stores stay open until both attempts settle, so the winner still ++ // holds the lock while the loser retries. ++ const stores = [useStore(directory, {}), useStore(directory, {})]; ++ const results = await Promise.allSettled( ++ stores.map((store) => ++ store.units.add({ id: `u-${randomUUID()}`, track: "build" }) ++ ) ++ ); ++ const fulfilled = results.filter((r) => r.status === "fulfilled"); ++ const rejected = results.filter( ++ (r): r is PromiseRejectedResult => r.status === "rejected" ++ ); ++ expect(fulfilled).toHaveLength(1); ++ expect(rejected).toHaveLength(1); ++ expect(String(rejected[0].reason)).toMatch(/store lock held by pid/); ++ expect(await readdir(directory)).not.toContain(".orch.lock.takeover"); ++ await Promise.all(stores.map((store) => store.close())); ++ }); ++ ++ it("does not unlink a stale lock while another process is mid-takeover", async () => { ++ const { directory } = await initializedStore(); ++ const exited = Bun.spawn(["true"]); ++ await exited.exited; ++ await writeFile(join(directory, ".orch.lock"), `${exited.pid}\n`); ++ // Simulate a competing process that has claimed the takeover but not yet ++ // installed its lock. ++ await writeFile(join(directory, ".orch.lock.takeover"), ""); ++ ++ const store = useStore(directory, {}); ++ await expect( ++ store.units.add({ id: "u1", track: "build" }) ++ ).rejects.toThrow(/store lock held by pid/); ++ expect(await readFile(join(directory, ".orch.lock"), "utf8")).toBe( ++ `${exited.pid}\n` ++ ); ++ await unlink(join(directory, ".orch.lock.takeover")); ++ }); ++ ++ it("steps past takeover markers left by crashed takers once they are past the age limit", async () => { ++ // One crash leaves the marker; a second leaves the marker that succeeded it. ++ for (const crashes of [1, 2]) { ++ const { directory } = await initializedStore(); ++ const exited = Bun.spawn(["true"]); ++ await exited.exited; ++ await writeFile(join(directory, ".orch.lock"), `${exited.pid}\n`); ++ const takeover = join(directory, ".orch.lock.takeover"); ++ const longAgo = new Date(Date.now() - 60_000); ++ let marker = takeover; ++ for (let crash = 0; crash < crashes; crash++) { ++ await writeFile(marker, ""); ++ await utimes(marker, longAgo, longAgo); ++ const seen = await stat(marker); ++ marker = `${takeover}.${seen.ino}-${Math.trunc(seen.mtimeMs)}`; ++ } ++ ++ const store = useStore(directory, {}); ++ expect( ++ await store.units.add({ id: "u1", track: "build" }) ++ ).toMatchObject({ id: "u1" }); ++ expect(await readFile(join(directory, ".orch.lock"), "utf8")).toBe( ++ `${process.pid}\n` ++ ); ++ expect( ++ (await readdir(directory)).filter((name) => ++ name.startsWith(".orch.lock.takeover") ++ ) ++ ).toEqual([]); ++ } ++ }); ++ ++ it("does not replace a lock another taker installed after the holder was judged dead", async () => { ++ const { directory } = await initializedStore(); ++ const exited = Bun.spawn(["true"]); ++ await exited.exited; ++ const lock = join(directory, ".orch.lock"); ++ await writeFile(lock, `${exited.pid}\n`); ++ // Between this process reading the dead holder and claiming the takeover, ++ // another taker finished its own takeover and now holds the lock. ++ const winner = Bun.spawn(["sleep", "30"]); ++ try { ++ const store = useStore(directory, { ++ onStaleLock: () => writeFileSync(lock, `${winner.pid}\n`), ++ }); ++ await expect( ++ store.units.add({ id: "u1", track: "build" }) ++ ).rejects.toThrow(`store lock held by pid ${winner.pid}`); ++ expect(await readFile(lock, "utf8")).toBe(`${winner.pid}\n`); ++ } finally { ++ winner.kill(); ++ } ++ }); ++ + it("blocks a writer and steals the pid lock only with force", async () => { + const { directory, store } = await initializedStore(); + await store.close(); +diff --git a/skills/poteto-mode/scripts/orch/store.ts b/skills/poteto-mode/scripts/orch/store.ts +--- a/skills/poteto-mode/scripts/orch/store.ts ++++ b/skills/poteto-mode/scripts/orch/store.ts +@@ -9,14 +9,19 @@ import { + readdir, + rename, + rm, ++ stat, + unlink, + writeFile, + } from "node:fs/promises"; + import { basename, dirname, join, resolve } from "node:path"; ++import { setTimeout as delay } from "node:timers/promises"; + + const UNIT_HEADER = "id\ttrack\tstate\tbranch\tpr\tsha\tbrief"; + const LEDGER_HEADER = "pr\tsha\tverdict\tevidence\tverifier\tts"; + const LOCK_FILE = ".orch.lock"; ++// A live takeover is a handful of file operations, so a takeover marker this ++// old was left by a taker that died mid-takeover and must not block the next. ++const TAKEOVER_MARKER_STALE_MS = 10_000; + + export type Verdict = + | "live-ui-verified" +@@ -384,6 +389,7 @@ async function acquireLock( + options: OpenStoreOptions + ): Promise<() => Promise> { + const path = join(store, LOCK_FILE); ++ const takeoverPath = `${path}.takeover`; + const pid = String(process.pid); + const create = async (): Promise => { + const handle = await open(path, "wx"); +@@ -391,17 +397,119 @@ async function acquireLock( + await handle.close(); + }; + +- const takeOver = async (): Promise => { +- await unlink(path); ++ const heldBy = async (): Promise => { ++ let retryHolder = "unknown"; + try { +- await create(); +- } catch (retryError) { +- if (errorCode(retryError) === "EEXIST") { +- const retryHolder = +- (await readFile(path, "utf8")).trim() || "unknown"; +- throw new UserError(`store lock held by pid ${retryHolder}`); ++ retryHolder = (await readFile(path, "utf8")).trim() || "unknown"; ++ } catch { ++ retryHolder = "unknown"; ++ } ++ throw new UserError(`store lock held by pid ${retryHolder}`); ++ }; ++ ++ // Claims the right to replace the lock by `wx`-creating a takeover marker, ++ // or returns null while another process holds a fresh one. A marker past the ++ // age limit is never unlinked to make room, because two processes that both ++ // judged it stale could each unlink it and the second would remove the ++ // first one's fresh marker. The next marker is named after the stale one's ++ // identity instead, so exactly one process can create it. The stale markers ++ // a claim stood on are removed on release, each only while it is still the ++ // file that was judged stale. ++ const claimTakeover = async (): Promise<(() => Promise) | null> => { ++ const stale: { ++ readonly path: string; ++ readonly ino: number; ++ readonly mtimeMs: number; ++ }[] = []; ++ let marker = takeoverPath; ++ for (;;) { ++ try { ++ await (await open(marker, "wx")).close(); ++ break; ++ } catch (claimError) { ++ if (errorCode(claimError) !== "EEXIST") { ++ throw claimError; ++ } ++ } ++ let seen; ++ try { ++ seen = await stat(marker); ++ } catch (statError) { ++ if (errorCode(statError) === "ENOENT") { ++ continue; ++ } ++ throw statError; ++ } ++ if (Date.now() - seen.mtimeMs < TAKEOVER_MARKER_STALE_MS) { ++ return null; ++ } ++ stale.push({ path: marker, ino: seen.ino, mtimeMs: seen.mtimeMs }); ++ marker = `${takeoverPath}.${seen.ino}-${Math.trunc(seen.mtimeMs)}`; ++ } ++ const claimed = marker; ++ return async () => { ++ for (const judged of stale) { ++ const current = await stat(judged.path).catch(() => null); ++ if (current?.ino === judged.ino && current.mtimeMs === judged.mtimeMs) { ++ await unlink(judged.path).catch(() => {}); ++ } ++ } ++ await unlink(claimed).catch(() => {}); ++ }; ++ }; ++ ++ // Replacing a stale lock is unlink-then-create, which is not atomic: two ++ // processes that both read the same dead holder could each unlink and the ++ // second unlink would remove the first one's fresh lock. Serialise the ++ // takeover itself through a second `wx` file so only one process performs ++ // the unlink; the other falls back to a plain create and, on EEXIST, reports ++ // the winner's pid. ++ const takeOver = async (holder: string): Promise => { ++ const release = await claimTakeover(); ++ if (release === null) { ++ // Another process is mid-takeover. Give it a moment to install its ++ // lock, then try the normal path once. ++ await delay(20); ++ try { ++ await create(); ++ return; ++ } catch (retryError) { ++ if (errorCode(retryError) === "EEXIST") { ++ return heldBy(); ++ } ++ throw retryError; ++ } ++ } ++ try { ++ // The marker serialises takers, so this read is current. A lock that no ++ // longer names the holder this process judged replaceable was installed ++ // by a taker that finished first, and is not this process's to remove. ++ let current: string | null = null; ++ try { ++ current = (await readFile(path, "utf8")).trim() || "unknown"; ++ } catch (readError) { ++ if (errorCode(readError) !== "ENOENT") { ++ throw readError; ++ } ++ } ++ // `return await`, not `return`: the finally below awaits, and a returned ++ // rejection nobody has awaited yet surfaces as an unhandled rejection. ++ if (current !== null) { ++ if (current !== holder) { ++ return await heldBy(); ++ } ++ await unlink(path); ++ } ++ try { ++ await create(); ++ } catch (retryError) { ++ if (errorCode(retryError) === "EEXIST") { ++ return await heldBy(); ++ } ++ throw retryError; + } +- throw retryError; ++ } finally { ++ await release(); + } + }; + +@@ -419,10 +527,10 @@ async function acquireLock( + } + if (holderIsDead(holder)) { + options.onStaleLock?.(holder); +- await takeOver(); ++ await takeOver(holder); + } else if (options.force) { + options.onLockStolen?.(holder); +- await takeOver(); ++ await takeOver(holder); + } else { + throw new UserError(`store lock held by pid ${holder}`); + } diff --git a/plugins/pstack/skills/poteto-mode/scripts/check-plan.mjs b/plugins/pstack/skills/poteto-mode/scripts/check-plan.mjs index 39c5b36..2414a33 100755 --- a/plugins/pstack/skills/poteto-mode/scripts/check-plan.mjs +++ b/plugins/pstack/skills/poteto-mode/scripts/check-plan.mjs @@ -72,7 +72,7 @@ for (let i = start; i < raw.length; i++) { lines.push({ n, text, code }); if (code) continue; const prose = text - .replace(/`[^`]*`/g, "`") + .replace(/(? { const result = runPlan("```markdown `invalid`\n## Example only\n```\n", 1); expect(result.stderr).toContain('"## Example only" after Close the program is not an appendix'); }); + +test("ignores prose punctuation inside matching multi-backtick code spans", () => { + for (const count of [2, 3, 4]) { + const delimiter = "`".repeat(count); + runPlan(`Use ${delimiter}echo \`x\`: “quoted” — example${delimiter}.\n`, 0); + } +}); + +test("checks prose following a multi-backtick span and unmatched delimiters", () => { + const outside = runPlan("Use ``echo `x`: y``. Bad: prose.\n", 1); + expect(outside.stderr).toContain("mid-sentence colon"); + const unmatched = runPlan("Use ``unclosed: bad` here.\n", 1); + expect(unmatched.stderr).toContain("mid-sentence colon"); +}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/orch/orch.test.ts b/plugins/pstack/skills/poteto-mode/scripts/orch/orch.test.ts index 2ef435d..75d2eda 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/orch/orch.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/orch/orch.test.ts @@ -6,9 +6,13 @@ import { readFile, readdir, rm, + stat, + unlink, + utimes, writeFile, } from "node:fs/promises"; -import { realpathSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -436,6 +440,104 @@ describe("Store", () => { expect(await readdir(directory)).not.toContain(".orch.lock"); }); + it("lets exactly one of two concurrent takers replace a stale lock", async () => { + const { directory } = await initializedStore(); + const exited = Bun.spawn(["true"]); + await exited.exited; + await writeFile(join(directory, ".orch.lock"), `${exited.pid}\n`); + + // Both stores stay open until both attempts settle, so the winner still + // holds the lock while the loser retries. + const stores = [useStore(directory, {}), useStore(directory, {})]; + const results = await Promise.allSettled( + stores.map((store) => + store.units.add({ id: `u-${randomUUID()}`, track: "build" }) + ) + ); + const fulfilled = results.filter((r) => r.status === "fulfilled"); + const rejected = results.filter( + (r): r is PromiseRejectedResult => r.status === "rejected" + ); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(String(rejected[0].reason)).toMatch(/store lock held by pid/); + expect(await readdir(directory)).not.toContain(".orch.lock.takeover"); + await Promise.all(stores.map((store) => store.close())); + }); + + it("does not unlink a stale lock while another process is mid-takeover", async () => { + const { directory } = await initializedStore(); + const exited = Bun.spawn(["true"]); + await exited.exited; + await writeFile(join(directory, ".orch.lock"), `${exited.pid}\n`); + // Simulate a competing process that has claimed the takeover but not yet + // installed its lock. + await writeFile(join(directory, ".orch.lock.takeover"), ""); + + const store = useStore(directory, {}); + await expect( + store.units.add({ id: "u1", track: "build" }) + ).rejects.toThrow(/store lock held by pid/); + expect(await readFile(join(directory, ".orch.lock"), "utf8")).toBe( + `${exited.pid}\n` + ); + await unlink(join(directory, ".orch.lock.takeover")); + }); + + it("steps past takeover markers left by crashed takers once they are past the age limit", async () => { + // One crash leaves the marker; a second leaves the marker that succeeded it. + for (const crashes of [1, 2]) { + const { directory } = await initializedStore(); + const exited = Bun.spawn(["true"]); + await exited.exited; + await writeFile(join(directory, ".orch.lock"), `${exited.pid}\n`); + const takeover = join(directory, ".orch.lock.takeover"); + const longAgo = new Date(Date.now() - 60_000); + let marker = takeover; + for (let crash = 0; crash < crashes; crash++) { + await writeFile(marker, ""); + await utimes(marker, longAgo, longAgo); + const seen = await stat(marker); + marker = `${takeover}.${seen.ino}-${Math.trunc(seen.mtimeMs)}`; + } + + const store = useStore(directory, {}); + expect( + await store.units.add({ id: "u1", track: "build" }) + ).toMatchObject({ id: "u1" }); + expect(await readFile(join(directory, ".orch.lock"), "utf8")).toBe( + `${process.pid}\n` + ); + expect( + (await readdir(directory)).filter((name) => + name.startsWith(".orch.lock.takeover") + ) + ).toEqual([]); + } + }); + + it("does not replace a lock another taker installed after the holder was judged dead", async () => { + const { directory } = await initializedStore(); + const exited = Bun.spawn(["true"]); + await exited.exited; + const lock = join(directory, ".orch.lock"); + await writeFile(lock, `${exited.pid}\n`); + // Between this process reading the dead holder and claiming the takeover, + // another taker finished its own takeover and now holds the lock. + const winner = Bun.spawn(["sleep", "30"]); + try { + const store = useStore(directory, { + onStaleLock: () => writeFileSync(lock, `${winner.pid}\n`), + }); + await expect( + store.units.add({ id: "u1", track: "build" }) + ).rejects.toThrow(`store lock held by pid ${winner.pid}`); + expect(await readFile(lock, "utf8")).toBe(`${winner.pid}\n`); + } finally { + winner.kill(); + } + }); + it("blocks a writer and steals the pid lock only with force", async () => { const { directory, store } = await initializedStore(); await store.close(); @@ -573,6 +675,40 @@ describe("Store", () => { }); }); + it(`resolves a replacement ${forge} PR alongside closed branch history`, async () => { + for (const historicalState of ["closed", "merged"] as const) { + const { directory, store } = await initializedStore(); + const stack = await makeGitStack(directory); + await withFakeForge({ + directory, forge, + currentRows: forgeRows(forge, [ + { number: 9, state: historicalState, head: "stack/open", base: "main" }, + { number: 11, state: "open", head: "stack/open", base: "stack/closed" }, + ]), + operation: async () => { + const frontier = await store.frontier.set({ repo: stack.repo, forge }); + expect(frontier.prs.map((row) => row.pr)).toEqual([10, 13, 11, 14]); + expect(frontier.lowestUnmerged).toBe(11); + }, + }); + } + }); + + it(`still rejects multiple active ${forge} PRs on one branch`, async () => { + const { directory, store } = await initializedStore(); + const stack = await makeGitStack(directory); + await withFakeForge({ + directory, forge, + currentRows: forgeRows(forge, [ + { number: 11, state: "open", head: "stack/open", base: "stack/closed" }, + { number: 12, state: "open", head: "stack/open", base: "main" }, + ]), + operation: async () => { + await expect(store.frontier.set({ repo: stack.repo, forge })).rejects.toThrow("multiple pull requests"); + }, + }); + }); + it(`rejects unparseable ${forge} output loudly`, async () => { const { directory, store } = await initializedStore(); const stack = await makeGitStack(directory); diff --git a/plugins/pstack/skills/poteto-mode/scripts/orch/store.ts b/plugins/pstack/skills/poteto-mode/scripts/orch/store.ts index 4173689..0ed8e8f 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/orch/store.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/orch/store.ts @@ -9,14 +9,19 @@ import { readdir, rename, rm, + stat, unlink, writeFile, } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; const UNIT_HEADER = "id\ttrack\tstate\tbranch\tpr\tsha\tbrief"; const LEDGER_HEADER = "pr\tsha\tverdict\tevidence\tverifier\tts"; const LOCK_FILE = ".orch.lock"; +// A live takeover is a handful of file operations, so a takeover marker this +// old was left by a taker that died mid-takeover and must not block the next. +const TAKEOVER_MARKER_STALE_MS = 10_000; export type Verdict = | "live-ui-verified" @@ -384,6 +389,7 @@ async function acquireLock( options: OpenStoreOptions ): Promise<() => Promise> { const path = join(store, LOCK_FILE); + const takeoverPath = `${path}.takeover`; const pid = String(process.pid); const create = async (): Promise => { const handle = await open(path, "wx"); @@ -391,17 +397,119 @@ async function acquireLock( await handle.close(); }; - const takeOver = async (): Promise => { - await unlink(path); + const heldBy = async (): Promise => { + let retryHolder = "unknown"; try { - await create(); - } catch (retryError) { - if (errorCode(retryError) === "EEXIST") { - const retryHolder = - (await readFile(path, "utf8")).trim() || "unknown"; - throw new UserError(`store lock held by pid ${retryHolder}`); + retryHolder = (await readFile(path, "utf8")).trim() || "unknown"; + } catch { + retryHolder = "unknown"; + } + throw new UserError(`store lock held by pid ${retryHolder}`); + }; + + // Claims the right to replace the lock by `wx`-creating a takeover marker, + // or returns null while another process holds a fresh one. A marker past the + // age limit is never unlinked to make room, because two processes that both + // judged it stale could each unlink it and the second would remove the + // first one's fresh marker. The next marker is named after the stale one's + // identity instead, so exactly one process can create it. The stale markers + // a claim stood on are removed on release, each only while it is still the + // file that was judged stale. + const claimTakeover = async (): Promise<(() => Promise) | null> => { + const stale: { + readonly path: string; + readonly ino: number; + readonly mtimeMs: number; + }[] = []; + let marker = takeoverPath; + for (;;) { + try { + await (await open(marker, "wx")).close(); + break; + } catch (claimError) { + if (errorCode(claimError) !== "EEXIST") { + throw claimError; + } + } + let seen; + try { + seen = await stat(marker); + } catch (statError) { + if (errorCode(statError) === "ENOENT") { + continue; + } + throw statError; + } + if (Date.now() - seen.mtimeMs < TAKEOVER_MARKER_STALE_MS) { + return null; + } + stale.push({ path: marker, ino: seen.ino, mtimeMs: seen.mtimeMs }); + marker = `${takeoverPath}.${seen.ino}-${Math.trunc(seen.mtimeMs)}`; + } + const claimed = marker; + return async () => { + for (const judged of stale) { + const current = await stat(judged.path).catch(() => null); + if (current?.ino === judged.ino && current.mtimeMs === judged.mtimeMs) { + await unlink(judged.path).catch(() => {}); + } + } + await unlink(claimed).catch(() => {}); + }; + }; + + // Replacing a stale lock is unlink-then-create, which is not atomic: two + // processes that both read the same dead holder could each unlink and the + // second unlink would remove the first one's fresh lock. Serialise the + // takeover itself through a second `wx` file so only one process performs + // the unlink; the other falls back to a plain create and, on EEXIST, reports + // the winner's pid. + const takeOver = async (holder: string): Promise => { + const release = await claimTakeover(); + if (release === null) { + // Another process is mid-takeover. Give it a moment to install its + // lock, then try the normal path once. + await delay(20); + try { + await create(); + return; + } catch (retryError) { + if (errorCode(retryError) === "EEXIST") { + return heldBy(); + } + throw retryError; + } + } + try { + // The marker serialises takers, so this read is current. A lock that no + // longer names the holder this process judged replaceable was installed + // by a taker that finished first, and is not this process's to remove. + let current: string | null = null; + try { + current = (await readFile(path, "utf8")).trim() || "unknown"; + } catch (readError) { + if (errorCode(readError) !== "ENOENT") { + throw readError; + } + } + // `return await`, not `return`: the finally below awaits, and a returned + // rejection nobody has awaited yet surfaces as an unhandled rejection. + if (current !== null) { + if (current !== holder) { + return await heldBy(); + } + await unlink(path); + } + try { + await create(); + } catch (retryError) { + if (errorCode(retryError) === "EEXIST") { + return await heldBy(); + } + throw retryError; } - throw retryError; + } finally { + await release(); } }; @@ -419,10 +527,10 @@ async function acquireLock( } if (holderIsDead(holder)) { options.onStaleLock?.(holder); - await takeOver(); + await takeOver(holder); } else if (options.force) { options.onLockStolen?.(holder); - await takeOver(); + await takeOver(holder); } else { throw new UserError(`store lock held by pid ${holder}`); } @@ -1113,15 +1221,19 @@ function branchPullRequest({ repo: string; }): ForgePullRequest { const rows = listPullRequests({ filter: "--head", branch, forge, repo }); - const row = rows[0]; + // A branch may have closed or merged historical PRs after stack surgery. + // Prefer its sole active PR, but retain historical anchors when none is open. + const active = rows.filter((row) => row.state === "OPEN"); + const candidates = active.length > 0 ? active : rows; + const row = candidates[0]; if (row === undefined) { throw new UserError( `branch ${branch} has no pull request; open one with ${forge} pr create, or resolve the frontier from a branch whose chain is fully submitted` ); } - if (rows.length > 1) { + if (candidates.length > 1) { throw new UserError( - `branch ${branch} has multiple pull requests (${rows.map((item) => `#${item.pr}`).join(", ")}); close the stale ones first` + `branch ${branch} has multiple pull requests (${candidates.map((item) => `#${item.pr}`).join(", ")}); close the stale ones first` ); } return row; diff --git a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts index 0656b41..7397753 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { type CliRuntime, main, parseArgs } from "./cli.ts"; import { fakeReader, passingCheck } from "./fakes.test-helper.ts"; +import { WatcherQueryError } from "./github.ts"; import { renderJson, renderPretty } from "./render.ts"; import type { GitHubReader, WatcherVerdict } from "./types.ts"; import { parsePrNumber } from "./types.ts"; @@ -103,6 +104,80 @@ describe("parseArgs", () => { }); }); +describe("heads without checks", () => { + const noChecks = () => + fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + + it("--status-only reports the head once instead of retrying", async () => { + const harness = testRuntime(noChecks()); + expect(await main(["--pr", "1", "--status-only"], harness.runtime)).toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).toBe("STATUS"); + if (verdict.kind !== "STATUS") throw new Error("expected STATUS"); + const row = verdict.rows[0]; + expect(row.kind).toBe("open"); + if (row.kind !== "open") throw new Error("expected open row"); + expect(row.ci.kind).toBe("ci-none"); + }); + + it("--status-only --pretty names the missing checks", async () => { + const harness = testRuntime(noChecks()); + expect( + await main(["--pr", "1", "--status-only", "--pretty"], harness.runtime) + ).toBe(0); + expect(harness.stdout.join("")).toContain("no checks reported"); + }); + + it("--status-only ends on the first query error instead of sleeping", async () => { + const reader = noChecks(); + const failing: GitHubReader = { + ...reader, + async pullRequest() { + throw new WatcherQueryError({ + kind: "json-parse", + retryable: true, + detail: "simulated outage", + }); + }, + }; + const harness = testRuntime(failing); + const code = await main(["--pr", "1", "--status-only"], harness.runtime); + expect(code).not.toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).not.toBe("RETRY"); + }); + + it("polling still treats a head without checks as a retryable error", async () => { + const harness = testRuntime(noChecks()); + const slept: number[] = []; + const runtime: CliRuntime = { + ...harness.runtime, + clock: { + ...harness.runtime.clock, + async sleep(seconds) { + slept.push(seconds); + if (slept.length >= 2) throw new Error("stop test"); + }, + }, + }; + await expect( + main(["--pr", "1", "--max-query-errors", "5"], runtime) + ).rejects.toThrow("stop test"); + expect(slept.length).toBe(2); + const first = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(first.kind).toBe("RETRY"); + }); +}); + describe("rendering", () => { const context = { owner: "owner", diff --git a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts index ec8a2f2..ea9f089 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts @@ -126,7 +126,11 @@ export function parseArgs( positiveInteger, 5 ) - .option("--status-only", "print one status table and exit 0", false) + .option( + "--status-only", + "read GitHub once, print one status table and exit 0; a head with no checks is reported, not retried", + false + ) .option("--allow-draft", "do not treat a draft as a merge gate", false) .option("--pretty", "render human text instead of JSON", false); program.parse(argv, { from: "user" }); diff --git a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts index 54722a9..4e58938 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { ChecksUnavailable, WatcherQueryError, @@ -418,19 +422,77 @@ describe("context and stack discovery", () => { { number: parsePrNumber(41), headRefName: "base-feature", + isCrossRepository: false, baseRefName: "main", }, { number: context.number, headRefName: "feature", + isCrossRepository: false, baseRefName: "base-feature", }, { number: parsePrNumber(43), headRefName: "upstack", + isCrossRepository: false, baseRefName: "feature", }, ]); expect(ordered.map((item) => Number(item.number))).toEqual([41, 42, 43]); }); + it("does not use a fork's same-named head as a stack parent", () => { + const ordered = orderStack(context, [ + { number: parsePrNumber(41), headRefName: "base-feature", baseRefName: "main", isCrossRepository: true }, + { number: context.number, headRefName: "feature", baseRefName: "base-feature", isCrossRepository: false }, + ]); + expect(ordered.map((item) => Number(item.number))).toEqual([42]); + }); + + it("does not attach upstream branch children to a fork head", () => { + const ordered = orderStack(context, [ + { number: context.number, headRefName: "feature", baseRefName: "main", isCrossRepository: true }, + { number: parsePrNumber(43), headRefName: "upstack", baseRefName: "feature", isCrossRepository: false }, + ]); + expect(ordered.map((item) => Number(item.number))).toEqual([42]); + }); + + it("terminates when an unrelated fork opens main against main", () => { + // Bound the subprocess so this regression reports a failure instead of hanging the suite. + const program = `import { orderStack } from ${JSON.stringify(import.meta.dir + "/github.ts")}; + console.log(JSON.stringify(orderStack(${JSON.stringify(context)}, [ + { number: 41, headRefName: "main", baseRefName: "main", isCrossRepository: true }, + { number: 42, headRefName: "feature", baseRefName: "main", isCrossRepository: false } + ])));`; + const child = spawnSync(process.execPath, ["-e", program], { encoding: "utf8", timeout: 2000 }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + expect(JSON.parse(child.stdout).map((item: { number: number }) => item.number)).toEqual([42]); + }); + + it("requests and parses fork identity from gh before stack discovery", () => { + const directory = mkdtempSync(join(tmpdir(), "watch-pr-fork-")); + try { + writeFileSync(join(directory, "gh"), `#!/bin/sh +case "$*" in + *number,headRefName,baseRefName,isCrossRepository*) ;; + *) exit 2 ;; +esac +printf '%s\\n' '[{"number":42,"headRefName":"feature","baseRefName":"main","isCrossRepository":true}]' +`, { mode: 0o755 }); + const program = `import { GhGitHubReader } from ${JSON.stringify(import.meta.dir + "/github.ts")}; + console.log(JSON.stringify(await new GhGitHubReader().openPullRequests({ owner: "owner", repo: "repo" })));`; + const child = spawnSync(process.execPath, ["-e", program], { + encoding: "utf8", timeout: 2000, + env: { ...process.env, PATH: directory + ":" + process.env.PATH }, + }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + expect(JSON.parse(child.stdout)).toEqual([ + { number: 42, headRefName: "feature", baseRefName: "main", isCrossRepository: true }, + ]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + }); diff --git a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.ts b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.ts index 4828b02..123a33b 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.ts @@ -500,11 +500,14 @@ export class GhGitHubReader implements T.GitHubReader { "--limit", "300", "--json", - "number,headRefName,baseRefName", + "number,headRefName,baseRefName,isCrossRepository", ]); return list(value, "open PRs").map((item, index) => { const object = record(item, `open PRs[${index}]`); + if (typeof object.isCrossRepository !== "boolean") + missing(`open PRs[${index}].isCrossRepository`, object.isCrossRepository); return { + isCrossRepository: object.isCrossRepository, number: parsePrNumber(object.number, `open PRs[${index}].number`), headRefName: string( object.headRefName, @@ -671,7 +674,11 @@ export function orderStack( open: readonly T.OpenPullRequest[] ): T.NonEmpty { const byNumber = new Map(open.map((pr) => [pr.number, pr])); - const byHead = new Map(open.map((pr) => [pr.headRefName, pr])); + // A base ref belongs to this repository. A fork head with the same name + // cannot be its parent, even when that name is the default branch. + const byHead = new Map( + open.filter((pr) => !pr.isCrossRepository).map((pr) => [pr.headRefName, pr]) + ); const children = new Map(); for (const pr of open) children.set(pr.baseRefName, [...(children.get(pr.baseRefName) ?? []), pr]); @@ -693,6 +700,7 @@ export function orderStack( ]); const up: T.OpenPullRequest[] = []; const visit = (parent: T.OpenPullRequest): void => { + if (parent.isCrossRepository) return; for (const child of children.get(parent.headRefName) ?? []) { if (seen.has(child.number)) continue; seen.add(child.number); diff --git a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts index 6dae16d..a675218 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts @@ -10,6 +10,7 @@ import { queryBackoffSeconds, readSnapshot, runQueued, + runSimple, selectTierMajorStackDecision, } from "./policy.ts"; import { @@ -91,6 +92,54 @@ describe("readiness truth table", () => { }); describe("snapshot query planning", () => { + it("reports a head without checks as ci-none when asked to", async () => { + const reader = fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + const snapshot = await readSnapshot({ + reader, + context: context(7), + pendingHistory: "include", + allowDraft: false, + noChecks: "report", + }); + expect(snapshot.kind).toBe("open"); + if (snapshot.kind !== "open") throw new Error("expected open snapshot"); + expect(snapshot.ci.kind).toBe("ci-none"); + expect(snapshot.ci.all).toEqual([]); + expect(snapshot.reviewAutomationRunning).toBe(false); + if (snapshot.ci.kind !== "ci-none") throw new Error("expected ci-none"); + expect(snapshot.ci.github.kind).toBe("allowed"); + expect(reader.calls).toContain("commitRollups"); + }); + + it("keeps a head without checks as a retryable query error by default", async () => { + const reader = fakeReader({ + fastPath: { kind: "checks", checks: [] }, + rollupPages: [{ checks: [], endCursor: null }], + }); + let caught: unknown = null; + try { + await readSnapshot({ + reader, + context: context(8), + pendingHistory: "include", + allowDraft: false, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(WatcherQueryError); + if (!(caught instanceof WatcherQueryError)) throw new Error("expected query error"); + expect(caught.failure.kind).toBe("checks-unavailable"); + expect(caught.failure.retryable).toBe(true); + }); + it("does not query commit rollups while queued checks are pending", async () => { const reader = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] }, @@ -418,3 +467,64 @@ it("uses the specified retry floor and cap", () => { expect(queryBackoffSeconds(1, 2)).toBe(120); expect(queryBackoffSeconds(60, 4)).toBe(300); }); + + +describe("polling deadline sleeps", () => { + for (const mode of ["single", "queued-stack", "query-retry"] as const) { + it(`caps ${mode} sleeps at the configured deadline`, async () => { + let now = 0; + const sleeps: number[] = []; + const pending = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] } }); + const reader = mode === "query-retry" + ? { ...pending, async pullRequest(): Promise { + throw new WatcherQueryError({ kind: "command-exit", retryable: true, code: 1, detail: "temporary failure" }); + } } + : pending; + const dependencies = { + reader, + clock: { + now: () => now, + observedAt: () => "2026-07-26T00:00:00.000Z", + async sleep(seconds: number) { sleeps.push(seconds); now += seconds; }, + }, + emit() {}, + }; + const polling = { ...options, interval: 60, timeout: 5 }; + const result = mode === "queued-stack" + ? await runQueued({ dependencies, contexts: [context(1)], options: polling }) + : await runSimple({ dependencies, contexts: [context(1)], mode: "single", statusOnly: false, options: polling }); + expect(result.kind).toBe("TIMEOUT"); + expect(sleeps).toEqual([5]); + expect(now).toBe(5); + }); + } + it("preserves the polling interval when the deadline is disabled", async () => { + let now = 0; + let reads = 0; + const sleeps: number[] = []; + const pending = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] } }); + const passed = fakeReader({ fastPath: { kind: "checks", checks: [passingCheck()] } }); + const result = await runSimple({ + dependencies: { + reader: { + ...pending, + async checksFastPath(pr) { + reads += 1; + return reads === 1 ? pending.checksFastPath(pr) : passed.checksFastPath(pr); + }, + }, + clock: { + now: () => now, + observedAt: () => "2026-07-26T00:00:00.000Z", + async sleep(seconds) { sleeps.push(seconds); now += seconds; }, + }, + emit() {}, + }, + contexts: [context(1)], mode: "single", statusOnly: false, + options: { ...options, interval: 60, timeout: 0 }, + }); + expect(result.kind).toBe("READY"); + expect(sleeps).toEqual([60]); + }); + +}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts index cabadb9..2552538 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -1,4 +1,5 @@ import { + ChecksUnavailable, WatcherQueryError, resolveChecks, resolveReviewThreads, @@ -61,6 +62,8 @@ export async function readSnapshot(args: { readonly context: T.PrContext; readonly pendingHistory: "include" | "omit"; readonly allowDraft: boolean; + /** `report`: a head with no checks becomes `ci-none`. `retry` (default): it stays a retryable query error, because a just-pushed head looks the same. */ + readonly noChecks?: "report" | "retry"; }): Promise { const facts = await args.reader.pullRequest(args.context); if (facts.state === "MERGED" || facts.mergedAt !== null) @@ -68,7 +71,30 @@ export async function readSnapshot(args: { if (facts.state === "CLOSED") return { kind: "closed", context: args.context, facts }; const threads = await resolveReviewThreads(args.reader, args.context); - const checks = await resolveChecks(args.reader, args.context); + let checks: T.CheckRead; + try { + checks = await resolveChecks(args.reader, args.context); + } catch (error) { + if (!(error instanceof ChecksUnavailable) || args.noChecks !== "report") + throw error; + const merge = await mergeAssessment(args.reader, facts); + return { + kind: "open", + context: args.context, + facts, + threads, + ci: { + kind: "ci-none", + source: "none", + all: [], + failed: [], + pending: [], + hadPreviousPassingCi: merge.hadPreviousPassingCi, + github: merge.github, + }, + reviewAutomationRunning: false, + }; + } const failed = nonEmpty( checks.checks.filter( (check): check is T.FailedCheck => check.kind === "failed" @@ -370,6 +396,12 @@ async function pollUntilTerminal(args: { }): Promise { let failures = 0; const started = args.dependencies.clock.now(); + const sleep = async (seconds: number): Promise => { + const remaining = args.options.timeout > 0 + ? Math.max(0, args.options.timeout - (args.dependencies.clock.now() - started)) + : seconds; + await args.dependencies.clock.sleep(Math.min(seconds, remaining)); + }; while (true) { let result: StepResult; try { @@ -400,7 +432,7 @@ async function pollUntilTerminal(args: { exitCode: 5, reason: { kind: "status-unavailable", failure: error.failure }, }); - await args.dependencies.clock.sleep(retryInSeconds); + await sleep(retryInSeconds); continue; } if (result.kind === "terminal") return result.verdict; @@ -410,7 +442,7 @@ async function pollUntilTerminal(args: { deadlinePassed(started, args.options, args.dependencies.clock.now()) ) return result.onDeadline(); - await args.dependencies.clock.sleep(result.seconds); + await sleep(result.seconds); } } } @@ -422,6 +454,12 @@ export async function runSimple(args: { readonly options: T.PollingOptions; }): Promise { const stamp = verdictFactory(args.dependencies.clock, args.mode); + // `--status-only` reads GitHub once: a head without checks is reported as + // such instead of being retried, and a query error ends the run on the + // first failure instead of sleeping through the backoff schedule. + const options: T.PollingOptions = args.statusOnly + ? { ...args.options, maxQueryErrors: 1 } + : args.options; const step = async (): Promise> => { const rows: T.PrSnapshot[] = []; for (const context of args.contexts) @@ -431,6 +469,7 @@ export async function runSimple(args: { context, pendingHistory: "include", allowDraft: args.options.allowDraft, + noChecks: args.statusOnly ? "report" : "retry", }) ); const complete = nonEmpty(rows); @@ -512,7 +551,7 @@ export async function runSimple(args: { }; return pollUntilTerminal({ dependencies: args.dependencies, - options: args.options, + options, stamp, step, }); diff --git a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/render.ts b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/render.ts index 0e93583..a0da9ac 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/render.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/render.ts @@ -13,6 +13,8 @@ function ciCell(row: T.PrSnapshot): string { return `❌ ${row.ci.failed.length} failed${row.ci.pending.length ? `, ${row.ci.pending.length} pending` : ""}${was}`; case "ci-github-rejected": return `❌ GitHub reports failing checks${was}`; + case "ci-none": + return `no checks reported${was}`; default: { const exhaustive: never = row.ci; return exhaustive; diff --git a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/types.ts b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/types.ts index 6c94e0a..6f6849b 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/types.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/types.ts @@ -52,6 +52,7 @@ export interface PullRequestFacts { } export interface OpenPullRequest { readonly number: PrNumber; + readonly isCrossRepository: boolean; readonly headRefName: string; readonly baseRefName: string; } @@ -141,7 +142,17 @@ export type CiClean = CiBase & { readonly pending: readonly []; readonly github: GitHubMergeAllowed; }; -export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean; +/** GitHub reported no checks at all for the head. Only `--status-only` produces this; polling keeps retrying, since a freshly pushed head has no checks yet either. */ +export interface CiNone { + readonly kind: "ci-none"; + readonly source: "none"; + readonly all: readonly []; + readonly failed: readonly []; + readonly pending: readonly []; + readonly hadPreviousPassingCi: boolean; + readonly github: GitHubMergeAssessment; +} +export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean | CiNone; export type PrSnapshot = | { readonly kind: "merged" | "closed"; From 6dd7f412a45e0513607de5251aaad6d9118f1d1c Mon Sep 17 00:00:00 2001 From: Negrescu Alexandru Date: Fri, 23 Oct 2026 00:00:40 +0300 Subject: [PATCH 09/10] slice b --- PORTING.md | 26 +++-- omp-port/check-port.sh | 105 +++++++++++------- omp-port/rules.sed | 100 +++++++++++++++-- omp-port/stale-exempt.tsv | 8 +- omp-port/test-gate-mutations.sh | 35 +++++- plugins/pstack/agents/poteto-agent.md | 2 +- plugins/pstack/skills/arena/SKILL.md | 2 +- plugins/pstack/skills/automate-me/SKILL.md | 2 +- plugins/pstack/skills/figure-it-out/SKILL.md | 2 +- plugins/pstack/skills/how/SKILL.md | 4 +- plugins/pstack/skills/interrogate/SKILL.md | 12 +- plugins/pstack/skills/no-comments/SKILL.md | 2 +- plugins/pstack/skills/poteto-help/SKILL.md | 56 +++++----- plugins/pstack/skills/poteto-mode/SKILL.md | 4 +- .../poteto-mode/playbooks/autopilot-full.md | 2 +- .../poteto-mode/playbooks/multi-phase-plan.md | 2 +- .../poteto-mode/playbooks/opening-a-pr.md | 4 +- .../poteto-mode/playbooks/orchestrate.md | 4 +- plugins/pstack/skills/pstack-omp/SKILL.md | 6 +- plugins/pstack/skills/recall/SKILL.md | 2 +- plugins/pstack/skills/reflect/SKILL.md | 16 +-- .../reflect/references/divergent-reviewer.md | 2 +- .../reflect/references/judgment-reviewer.md | 2 +- .../reflect/references/tooling-reviewer.md | 2 +- .../pstack/skills/show-me-your-work/SKILL.md | 2 +- plugins/pstack/skills/swarm/SKILL.md | 2 +- plugins/pstack/skills/why/SKILL.md | 8 +- 27 files changed, 276 insertions(+), 138 deletions(-) diff --git a/PORTING.md b/PORTING.md index 85b9ae0..6664171 100644 --- a/PORTING.md +++ b/PORTING.md @@ -234,8 +234,9 @@ correct tree. They now derive the name the way the preamble tells the agent to. The gate used to assert that port-authored prose sat *inside* upstream's playbooks, which is the opposite of mirroring upstream and made every one of those assertions a rule that went stale. Those -checks are gone. What remains is residue detection: Cursor wire fields, model slugs, retired role -keys, install paths, and build integrity. +checks are gone. What remains is residue detection: Cursor wire fields and tool names, Cursor's +transcript layout, model slugs, retired role keys, install paths, a claimed `hub` tool, an unscoped +`omp cleanse --all` recommended as a diagnostic, and build integrity. `skills/setup-pstack` is port-owned, so the build restores it from `HEAD` and never rebuilds it from upstream. Upstream's version writes a Cursor rules file with no omp meaning, so the fork is @@ -255,8 +256,9 @@ Known deltas the port mirrors faithfully and will not diverge on. The guide says omp ignores the skill frontmatter keys `mode:` and `reminder:`, which is how Cursor pins `/poteto-mode` as a Custom Mode (`Opt+Enter`). That is reproduced natively in `~/.omp/agent/extensions/potetomode/index.js` using the pattern proven by the installed `ponytail` -extension, `appendEntry` to persist, `session_start` plus `getBranch()` to restore, -`before_agent_start` to inject, and `registerCommand` for `/poteto-mode`. +extension, `appendEntry` to persist, `session_start`, `session_switch`, `session_branch`, and +`session_tree` plus `getBranch()` to restore, `before_agent_start` to inject, and `registerCommand` +for `/poteto-mode`. The injected reminder is a pointer, not the playbook. It tells the agent to read `skill://poteto-mode` on demand instead of inlining the whole SKILL.md every turn. @@ -298,11 +300,17 @@ The injected reminder is a pointer, not the playbook. It tells the agent to read experiment rather than the answer. This repository neither ships nor requires it. - **Sibling coordination.** `write agent://` steers or follows up with a running, idle, or parked worker, `agent://all` broadcasts to visible live peers, `read history://` lists registered agents - with status and parent, and `read proc://` lists background jobs and project services. These are - the sanctioned primitives when workers must coordinate instead of running independent. The - orchestrator uses its own TSV store and `orch inbox` for the queue instead, so sibling messaging - stays unused there. Cross-session messaging does not exist locally and gets no workaround here. It - is tracked upstream and stays open. + with status and parent, `read proc://` lists background jobs and project services, and the `wait` + tool blocks until the next settled job or peer message. These are the sanctioned primitives when + workers must coordinate instead of running independent. There is no `hub` tool. Probe it rather + than trusting this line: `grep -a -A40 'src/tools/builtin-names.ts' "$(readlink -f "$(command -v omp)")"` + prints the binary's builtin tool list, which on 18.12.6 carries `wait` and no `hub`; the binary's + only other `hub` is the `/hub` slash command that opens Agent Hub. An earlier tree read a `hub` + tool with list/send/inbox/wait ops off a stale pi-coding-agent 17.x package, and the gate now + fails any shipped sentence that names one. The orchestrator uses its own TSV store and + `orch inbox` for the queue instead, so sibling messaging stays unused there. Cross-session + messaging does not exist locally and gets no workaround here. It is tracked upstream and stays + open. ## Still missing in omp diff --git a/omp-port/check-port.sh b/omp-port/check-port.sh index 7039d8f..74cc736 100755 --- a/omp-port/check-port.sh +++ b/omp-port/check-port.sh @@ -150,11 +150,17 @@ fi runtime_contract() { local bad label pattern - for label in per-call-model pstack-rule-file task-environment; do + for label in per-call-model pstack-rule-file task-environment unscoped-cleanup history-layout; do case "$label" in - per-call-model) pattern='subagent_type|run_in_background|`model`:\s*[^f]|Set `model`|set `model` to|with `model` from|omit `model` so' ;; + per-call-model) pattern='subagent_type|run_in_background|generalPurpose|`model`:\s*[^f]|Set `model`|set `model` to|with `model` from|omit `model` so' ;; pstack-rule-file) pattern='pstack-models\.mdc' ;; task-environment) pattern='environment:[[:space:]]*"?((local)|(cloud))\b|cloud_base_branch' ;; + # `omp cleanse` detects AND fixes with parallel subagents, and `--all` runs every checker + # repo-wide, so recommending it as a precommit diagnostic edits files outside the diff. + unscoped-cleanup) pattern='omp[[:space:]]+cleanse[[:space:]]+(--all|-a)([[:space:]]|`|$)' ;; + # Cursor's per-project transcript directory and its three layouts. omp writes one layout, + # described in omp-mechanics/references/session-store-on-omp.md. + history-layout) pattern='agent-transcripts|/subagents/|read the first JSONL line|legacy flat|message\.content\[0\]' ;; esac bad=$(grep -rInE "$pattern" --include='*.md' --include='*.mjs' --include='*.ts' --include='*.sh' "${SCOPE[@]}" 2>/dev/null || true) if [ -n "$bad" ]; then @@ -174,24 +180,20 @@ runtime_contract() { report "retired runtime labels" "PASS no abstract role labels, placeholders, or stale process wording" fi - # `hub` IS a real built-in tool on this harness: sibling messaging, settled-job inspection, and - # supervised services, with ops list/send/inbox/wait/cancel/jobs/start/logs/stop. Verified in the - # installed package -- it is in pi-coding-agent's canonical tool set, and the binary carries the - # HubTool string. It is registered only when tool names are not restricted and IRC is enabled. - # - # This assertion used to be the opposite: it failed any backticked `hub`, enforcing a denial that - # was not true, with a mutation pinning the wrong behaviour. A line that claims hub does not - # exist is now the defect, because that is the sentence that sends an agent away from a live - # primitive. What is required instead is that where the port routes sibling coordination or a - # supervised service, it names the surface that is actually there. - if grep -rqE 'no (programmatic )?`?hub`? tool' --include='*.md' --include='*.mjs' --include='*.ts' \ - --include='*.sh' "${SCOPE[@]}" 2>/dev/null; then + # There is no `hub` tool. omp's own builtin tool list, packages/coding-agent/src/tools/builtin-names.ts + # inside the binary, carries `wait` and no `hub`; the binary's only other `hub` is the `/hub` + # slash command that opens Agent Hub, a human-facing TUI. The probe is in PORTING.md under + # "Sibling coordination". An earlier tree claimed a `hub` tool with list/send/inbox/wait/cancel/ + # jobs/start/logs/stop ops, read off a stale pi-coding-agent 17.x package, and this assertion + # enforced that claim. A sentence that names `hub` as a tool sends an agent to a primitive that + # is not there; `wait`, `proc://`, `write agent://`, and named `bash` services are. + hub_claim='`hub`|\bhub[` ]+(tool|tools|op|ops|operation|operations)\b' + bad=$(grep -rInE "$hub_claim" --include='*.md' --include='*.mjs' --include='*.ts' --include='*.sh' "${SCOPE[@]}" 2>/dev/null || true) + if [ -n "$bad" ]; then report "agent hub api" "FAIL" - while read -r line; do violate "denies a live primitive: $line"; done <<<"$(grep -rnE 'no (programmatic )?`?hub`? tool' --include='*.md' --include='*.mjs' --include='*.ts' --include='*.sh' "${SCOPE[@]}" 2>/dev/null)" - elif grep -rqF 'hub' --include='*.md' "${SCOPE[@]}" 2>/dev/null; then - report "agent hub api" "PASS hub is documented as a real conditional built-in" + while read -r line; do violate "claims a hub tool omp does not ship: $line"; done <<<"$bad" else - report "agent hub api" "SKIP no hub reference in the tree to check" + report "agent hub api" "PASS no hub tool claimed; coordination names wait, proc://, and agent://" fi # One install root. A path under any other agent store resolves to nothing on this machine, @@ -238,12 +240,14 @@ runtime_contract() { report "external capability provenance" "PASS the external veto is named and marked unshipped" fi - bad=$(grep -rInE '`(Read|Grep|Glob|Shell|Task)` (tool calls|prompts|response body)|Tool calls \(Shell,|Use Glob to find|Use Read, Grep|Subagents inherit it' --include='*.md' "${SCOPE[@]}" 2>/dev/null || true) + # omp's tools are lowercase. A capitalised `Task` names Cursor's spawn tool, and in a reviewer + # template it makes the lens grep a transcript for a tool that was never called. + bad=$(grep -rInE '`(Read|Grep|Glob|Shell)` (tool calls|prompts|response body|of)|`Task`|\bTask (tool|subagent|spawn|call|schema)s?\b|[Tt]ool calls? \(Shell,|Use Glob to find|Use Read, Grep|Subagents inherit it' --include='*.md' "${SCOPE[@]}" 2>/dev/null || true) if [ -n "$bad" ]; then report "cursor tool names" "FAIL" while read -r line; do violate "$line"; done <<<"$bad" else - report "cursor tool names" "PASS no prompt template names a Cursor tool" + report "cursor tool names" "PASS no Cursor tool name, capitalised Task included" fi # Derived from the tree, not from a list: a hand-kept list of names is a list that forgets. @@ -332,6 +336,31 @@ runtime_contract() { } runtime_contract +# Claims the published guide (Pt.1 and Pt.2) makes about pstack, verified as present. Tree-local, +# so it runs before the contracts-only exit and the fixture mutations can reach it. The playbook +# count the guide states is asserted by `playbook parity`, from the index, not restated here. +unclaimed="" +guide_skills=(create-verification-skill maintain-verification-skill swarm poteto-mode teach how why recall technical-writing unslop architect) +guide_files=(skills/poteto-mode/playbooks/prototype.md skills/poteto-mode/playbooks/multi-phase-plan.md skills/poteto-mode/scripts/check-plan.mjs) +for s in "${guide_skills[@]}"; do + [ -f "skills/$s/SKILL.md" ] || unclaimed="$unclaimed""guide names /$s, not installed"$'\n' +done +for f in "${guide_files[@]}"; do + [ -f "$f" ] || unclaimed="$unclaimed""guide names $f, not shipped"$'\n' +done +grep -qi 'features' skills/create-verification-skill/SKILL.md 2>/dev/null || + unclaimed="$unclaimed""create-verification-skill must build the Feature Map (references/features)"$'\n' +# Upstream removed `mode: true` on purpose (b93e811) and omp never modelled it, so asserting that +# key only asserted a deleted line. The extension is what registers the command. +grep -q 'registerCommand("poteto-mode"' extensions/potetomode/index.js 2>/dev/null || + unclaimed="$unclaimed""the potetomode extension must register /poteto-mode"$'\n' +if [ -n "$unclaimed" ]; then + report "guide claims" "FAIL" + while read -r c; do [ -n "$c" ] && violate "$c"; done <<<"$unclaimed" +else + report "guide claims" "PASS ${#guide_skills[@]} skills, prototype and multi-phase-plan playbooks, check-plan.mjs, Feature Map, pin" +fi + if [ "${CHECK_PORT_CONTRACTS_ONLY:-}" = 1 ]; then echo if [ "$fail" -eq 0 ]; then echo "check-port contracts: PASS"; else echo "check-port contracts: FAIL"; fi @@ -347,7 +376,7 @@ if [ "${CHECK_PORT_SKIP_MUTATIONS:-}" = 1 ]; then else mutation_log=$(mktemp) if bash "$PORT_DIR/test-gate-mutations.sh" >"$mutation_log" 2>&1; then - report "gate mutations" "PASS ownership, alias, effort, and per-file lever mutations rejected" + report "gate mutations" "PASS $(grep -c 'rejected$' "$mutation_log") fixture mutations rejected, each by its own check" else report "gate mutations" "FAIL" while read -r line; do [ -n "$line" ] && violate "$line"; done <"$mutation_log" @@ -355,6 +384,21 @@ else rm -f "$mutation_log" fi +# The patched worktree-audit.sh is a script, so its behaviour needs a run, not a grep. Its test +# lives beside this gate and is cwd-independent. +if [ -f "$PORT_DIR/test-worktree-audit.sh" ]; then + audit_log=$(mktemp) + if bash "$PORT_DIR/test-worktree-audit.sh" >"$audit_log" 2>&1; then + report "worktree audit" "PASS omp-port/test-worktree-audit.sh" + else + report "worktree audit" "FAIL" + while read -r line; do [ -n "$line" ] && violate "$line"; done <"$audit_log" + fi + rm -f "$audit_log" +else + report "worktree audit" "SKIP omp-port/test-worktree-audit.sh not present" +fi + missing="" while read -r p; do [ -e "skills/poteto-mode/$p" ] || missing="$missing $p" @@ -451,25 +495,6 @@ if [ -f "$ALLOW" ]; then fi fi -# Claims the published guide makes about pstack, verified as present. -unclaimed="" -for s in create-verification-skill maintain-verification-skill swarm poteto-mode; do - [ -f "skills/$s/SKILL.md" ] || unclaimed="$unclaimed""guide names /$s, not installed"$'\n' -done -grep -qi 'features' skills/create-verification-skill/SKILL.md || - unclaimed="$unclaimed""create-verification-skill must build the Feature Map (references/features)"$'\n' -# Upstream removed `mode: true` on purpose (b93e811) and omp never modelled it, so asserting that -# key only asserted a deleted line. The extension is what registers the command. -grep -q 'registerCommand("poteto-mode"' extensions/potetomode/index.js || - unclaimed="$unclaimed""the potetomode extension must register /poteto-mode"$'\n' -if [ -n "$unclaimed" ]; then - report "guide claims" "FAIL" - while read -r c; do [ -n "$c" ] && violate "$c"; done <<<"$unclaimed" -else - report "guide claims" "PASS verification skill, Feature Map, swarm, pin" -fi - - bad="" for d in skills/*/; do n=$(basename "$d") diff --git a/omp-port/rules.sed b/omp-port/rules.sed index abc9e17..e1d4597 100644 --- a/omp-port/rules.sed +++ b/omp-port/rules.sed @@ -10,6 +10,63 @@ ## 1. Whole-sentence rewrites, which must read raw upstream text before any token rule edits it. +# Comment Sicko's actual upstream name is the lowercase agent id, and it has to match before the +# generic field-token rules below rewrite `subagent_type` out from under it. +s#^1\. Spawn `Task` with `subagent_type: "comment-sicko"`\..*$#1. Start one `task` call with one item in `tasks[]` and the required shared `context`. Use the exact discovered `comment-sicko` agent when present; otherwise omit `agent` for the default worker. Resolve the loaded `skill://no-comments` path to its plugin root and put the comment-reviewer role, scope, and absolute `/agents/comment-sicko.md` pointer in the brief. Agent frontmatter or its exact override selects reasoning and speed. Do not restate the reviewer's rules.# +# The how and why contracts are one paragraph upstream, not role bullets. Cursor's `generalPurpose` +# agent does not exist on omp, so the whole paragraph goes, before the token rules see it. +s#^Every spawn is a Task subagent with `subagent_type: generalPurpose`.*$#Every delegation uses one `task` call with the required shared `context` and all independent items in `tasks[]`. Resolve exact agent names against the live roster, not configuration keys. Repository explorers use a discovered `scout`; explainers use a discovered explainer or omit `agent` for the default worker with the explainer brief. Each item's `task` is standalone and carries its angle and prompt template. All work is read-only. Models, reasoning, and speed come from agent frontmatter or `task.agentModelOverrides[]`, never per-call model fields or family fallbacks. Overrides cannot create agents.# +s#^Every spawn is `generalPurpose` with `readonly: false`.*$#Use exact discovered investigators and a synthesizer from the live roster. Omit `agent` for the default worker when no matching specialist is loaded. MCP tools are inherited, including by `scout`; choose a worker whose exposed tools cover the assigned evidence source. Carry read-only posture in each brief: no file writes, git mutations, commits, pushes, PRs, or external mutations. There is no task `readonly` field.# +# Inline why role annotations are not task model selectors. +s#`model`: the `why investigators` line, default `[^`]+`\.#Use an exact discovered investigator agent; its frontmatter or `task.agentModelOverrides[]` selects the model.# +s#`model`: the `why synthesizer` line, default `[^`]+`\.#Use an exact discovered synthesizer or omit `agent` for the default worker with the synthesizer brief. Its frontmatter or exact override selects the model.# +# Interrogate writes its call contract as prose and its defaults as a two-seat slug table. Both run +# before the slug tiers, which would otherwise leave a capability name in a "Default model" column. +s#^Each reviewer gets `subagent_type: generalPurpose`, `readonly: true`, and `model` set to its configured entry,.*$#Each task item uses an exact discovered reviewer agent or omits `agent` for the default worker with the same reviewer brief. Carry read-only posture in the brief, not a `readonly` field. Agent frontmatter or `task.agentModelOverrides[]` selects the model; do not pass `model`. Shared `context` carries the intent and rubric, while each item carries the standalone review task.# +s#^\| Subagent \| Default model \|$#| Reviewer slot | Runtime agent |# +s#^\| Reviewer A \| `[^`]+` \|$#| Reviewer A | discovered reviewer agent or default worker |# +s#^\| Reviewer B \| `[^`]+` \|$#| Reviewer B | independently configured reviewer agent or default worker; report unavailable diversity |# +# Reasoning and speed are configured role properties, not routed-skill task fields. The router's +# paragraph tunes a per-call Cursor slug and retries a family default on rejection; omp's task item +# takes no model, and its only per-spawn knob is the gated `effort` field. +s#Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the \*\*poteto-mode\*\* skill\.#Use the reasoning and speed already selected by agent frontmatter or exact `task.agentModelOverrides` entries. Do not change operator configuration to satisfy a skill example.#g +s#^\*\*Match the model to the job\.\*\*.*$#**Match the role to the job.** Resolve the required capability against the live agent roster and use that agent's configured reasoning and speed. Pass a per-spawn `effort` only when the live task schema exposes it. Agent frontmatter or `task.agentModelOverrides[]` selects the model. Do not adjust a per-call slug, retry a family default, or change operator configuration to satisfy an example. Report unavailable diversity honestly.# +# Poteto help's install, model defaults, pin UI, and guide links describe Cursor. On omp the install +# is the marketplace, the model map is config.yml, and the pin is the potetomode extension's +# `/poteto-mode on|off|status`, `alt+shift+t`, and `--poteto` flag. These run before the +# `/skill:` rule, which would otherwise rewrite the `/poteto-mode` their left sides quote. +s#^1\. Install with `/add-plugin pstack` in chat, or from Customize in the sidebar\.$#1. Install with `/marketplace add negoro26/pstack-omp`, then `/marketplace install pstack@pstack-omp`.# +s#^2\. Run \[`/setup-pstack`\].*$#2. Run [`/setup-pstack`](../setup-pstack/SKILL.md). It selects models and a reasoning budget for exact discovered agents, writing three pstack-owned `modelRoles` aliases and `task.agentModelOverrides` entries in `~/.omp/agent/config.yml`. It does not create missing agents or select the chat model. Read the skill for activation and optional verification-skill setup.# +s#^- No `~/\.cursor/rules/pstack-models\.mdc` means.*$#- No exact override for a role means its loaded agent frontmatter or the parent chat model selects its model. Absence of an override does not prove setup was never run.# +s#When the model rule is missing and it matters,#When required role overrides are missing and model selection matters,# +s#^- Later: answer their question, and add one line saying every role keeps its default model until they run `/setup-pstack`\.$#- Later: answer their question, and say agent frontmatter or parent inheritance remains in effect until they configure exact role overrides.# +s#^If cost is the worry, say where the tokens go and how to spend fewer\..*$#If cost is the worry, explain that extra workers and review panels spend more tokens. Rerun `/setup-pstack` to choose a smaller reasoning budget or cheaper available models. An agent without a model override uses its frontmatter or inherits the chat model. Fewer configured reviewer slots mean fewer subagents. Never write `auto` or `inherit-parent` as an OMP model selector. Save `/poteto-mode` for work that needs rigor.# +s#Installing changes nothing until the user invokes a skill\. Only `/setup-pstack` loads from the user's words\.#Skills run when invoked or routed. The installed extension also provides the session pin commands. `setup-pstack` is available for model-configuration requests.# +s#^Whether `/poteto-mode` stays on depends on how the user starts it:$#Choose whether to invoke the router once or pin it for the session:# +s#^- Enter on `/poteto-mode` attaches.*$#- `/skill:poteto-mode` invokes the router for a task; it does not enable the persistent pin.# +s#^- Option\+Enter on Mac or Alt\+Enter on Windows,.*$#- `/poteto-mode on` pins the router for every turn, `/poteto-mode off` unpins it, and `/poteto-mode status` reports the current pin. These are the installed extension's OMP commands, not Cursor's Custom Mode UI.# +s#^- Cursor's docs list Custom Modes.*$#- The extension registers Alt+Shift+T to toggle the pin when the terminal delivers that key. For print-mode runs use `omp --poteto -p ''`. Do not claim Cursor's platform shortcuts work here.# +s#^Link \[Cursor's skills docs\].*$#Link the installed [OMP README](../../README.md) for pin commands and [runtime mapping](../pstack-omp/SKILL.md) for delegation. Mid-chat, "new task" makes the router match a fresh playbook. Resolve any helper against the live agent roster; use `poteto-agent` only when loaded, otherwise follow the runtime mapping's role and default-worker routing.# +s#a Custom Mode keeps it on#`/poteto-mode on` keeps it pinned#g +s#^\| The mode stopped applying after a few turns \|.*$#| The mode stopped applying after a few turns | Check `/poteto-mode status`. Use `/poteto-mode on` to pin it, or invoke `/skill:poteto-mode` for each task. |# +s#^\| A new model choice had no effect \|.*$#| A new model choice had no effect | Confirm the loaded agent name and its exact `task.agentModelOverrides` entry. Follow `/skill:setup-pstack` activation guidance; an override cannot create an agent. |# +s#^\| A skill didn't load on its own \|.*$#| A skill didn't load on its own | Invoke `/skill:` explicitly or let the router load it. Only skills enabled for model invocation appear in the per-turn index. |# +s#which each get their own machine#which get private worktrees, not separate machines#g +s#^- `/loop` and `/create-skill` are Cursor built-ins\.$#- `/loop` is an OMP runtime command when exposed. Skill authoring uses the [authoring playbook](../poteto-mode/playbooks/authoring-a-skill.md); there is no assumed Cursor `/create-skill` command.# +s#^Without `/poteto-mode`, a phrase such as.*$#Without `/poteto-mode`, the runtime may route a phrase using other enabled skills. Invoke the router explicitly to select pstack's playbook. The Playbooks section of [`poteto-mode`](../poteto-mode/SKILL.md) lists every playbook and when it applies. [Guide page 6](../../docs/guide/06-verify-and-ship.md) gives upstream workflow examples, not OMP runtime commands.# +# omp has a plan mode of its own, so the sentence names no Cursor feature rather than denying one. +s#pstack has no planning skill\. Cursor's Plan Mode works alongside it\.#pstack has no planning skill.# +s#Typing `/principle-` still loads one on demand\.#Typing `/skill:principle-` loads one on demand.# +# The build extracts only skills/ and agents/, so a relative guide link resolves to nothing. Point +# it at upstream's public copy, and the public-copy rule at this port's tree. +s#\]\(../../docs/guide/([0-9][0-9]-[a-z-]+\.md)\)#](https://github.com/cursor/plugins/blob/main/pstack/docs/guide/\1)#g +s#The links here point into the installed plugin, which the user may not be able to open, so give the user the file's public copy: `https://github.com/cursor/plugins/blob/main/pstack/` followed by its path\.#Link installed skill and runtime files directly. For a public copy of ported behavior, use `https://github.com/negoro26/pstack-omp/blob/main/plugins/pstack/` followed by the installed plugin-relative path. Guide URLs point to upstream for workflow background only; use this port's README and runtime skills for OMP commands.# +# Poteto help's companion list. `omp cleanse` detects AND fixes repo-wide, so it is no diagnostic. +s#^- `/deslop`, `control-cli`, and `control-ui` ship in the `cursor-team-kit` plugin\.$#- Prose cleanup uses `skill://unslop`. Diagnostics use the project's documented read-only checks, with LSP diagnostics only when a live server is configured. OMP exposes browser interaction through its eval prelude and CLI interaction through named bash services and `proc://`. Do not run `omp cleanse` as a diagnostic check.# +s#^Other tools can read the skills, because they use the Agent Skills format.*$#The engineering workflows come from upstream. `skill://pstack-omp` maps worker execution and lifecycle to OMP; `skill://setup-pstack` configures OMP models, and the mode extension preserves the session pin.# +# Reflect's lens table loses Cursor's default-model column below, so its separator loses one too. +/^## 2\. Spawn three reviewers in parallel$/,/^## 3\. Synthesize$/s#^\|---\|---\|---\|---\|$#|---|---|---|# + # Cursor's reasoning-effort ladder over its own slug names -> a selector omp models reports. The # left-hand side is upstream's sentence as it stands at the pin, nothing more: it used to end "when # only that form is detected.", a clause upstream dropped, and the rule then matched nothing at all @@ -56,16 +113,16 @@ s#Work like a careful, cautious, precise investigator\.#Work like a careful, cau s#Spawn one Task subagent that explores and explains in one pass:#Start one direct explainer in one `task` call with one item in `tasks[]` and the required shared `context`:# s#Once all explorers have returned, spawn one Task subagent to synthesize their findings into one explanation:#After all explorers have returned, start the synthesis in one `task` call with one item in `tasks[]` and the required shared `context`, using Step 2b's explainer routing:# s#Spawn one synthesizer subagent:#Start one synthesizer in one `task` call with one item in `tasks[]`, the required shared `context`, and an exact discovered full-access worker:# -s#Spawn all explorers in a single message:#Spawn all explorers in one `task` call with the required shared `context` and all items in `tasks[]`:# +s#Spawn all explorers in a single message,#Start all explorers in one `task` call with the required shared `context` and all independent items in `tasks[]`,# s#Launch all matching investigators in a single message so they run concurrently\.#Launch all matching investigators in one `task` call with the required shared `context` and all items in `tasks[]` so they run concurrently.# -s#^Launch all reviewers in a single message using the Task tool\. Use the `interrogate reviewers` line in `~/\.cursor/rules/pstack-models\.mdc`, one reviewer per entry, extending or shrinking the Reviewer A/B/C labels below to the configured entry count\. If the rule or that line is missing, use the table defaults\.$#Launch all reviewers in one `task` call with the required shared `context` and all items in `tasks[]`. Resolve exact discovered reviewer agent names, use one agent per reviewer slot, and report when the live roster cannot provide the requested model-family diversity.# +s#^Launch all reviewers in a single message with the Task tool, one per entry on the `interrogate reviewers` line in `~/\.cursor/rules/pstack-models\.mdc`, labeled Reviewer A, B, and so on to match the entry count\. If the rule or that line is missing, use the table defaults\.$#Launch all reviewers in one `task` call with the required shared `context` and all items in `tasks[]`. Resolve exact discovered reviewer agents from the live roster, one per configured slot, labeled Reviewer A, B, and so on. Omit `agent` for the default worker with the reviewer brief when a specialist is absent. Report unavailable model-family diversity; never invent an agent from an override key.# # Cursor's per-reviewer opening becomes one reviewed batch. s#^Spawn one reviewer per configured model to adversarially review code changes\. Each model gets the same prompt and rubric\.#Use exact discovered reviewer agents, one per configured family, in one `task` call with all items in `tasks[]` and the required shared `context`. Each reviewer gets the same prompt and rubric.# s#Spawn all N subagents in one message with `run_in_background: true`, each with the task, the path to the shared grounding, its own output path, and instructions to produce both the artifact and a short rationale\.#Spawn all N subagents in one `task` call with all items in `tasks[]` and the required shared `context`, each with the task, the path to the shared grounding, its own output path, and instructions to produce both the artifact and a short rationale.# s#Spawn all N workers in one message with `subagent_type: generalPurpose`, `environment: "cloud"`, `run_in_background: true`, and the step 4 model, left unset for `auto` or `inherit-parent`\. Use `environment: "local"` only when the worker needs access to something on the user's computer\.#Start all N workers in one `task` call. Put every item in `tasks[]` and pass the required shared `context`; set `isolated: true` only when the live task schema exposes it and the runtime's isolation settings allow it. Otherwise give each writer an explicit separate worktree or output directory.# -s#One message, three `Task` calls, `subagent_type: generalPurpose`, (explicit `model:` on each|with `model` set as below), agent mode \(`readonly: false`\)\.#One `task` call with three items in `tasks[]` and one required shared `context`, each item using an exact discovered agent name. Full tools per spawn. Run the three lenses on three different configured model families where available, and keep Divergent on a different model family from Judgment, since the lens earns its name from different priors and not a different prompt. Reviewers need full tools for MCP lookups (tickets, chat threads, observability traces referenced in the transcript); there is no task `readonly` field.# +s#^One message, three `Task` calls, `subagent_type: generalPurpose`, agent mode \(`readonly: false`\)\..*$#Start the three lenses in one `task` call with three items in `tasks[]` and the required shared `context`. Resolve exact discovered judgment, tooling, and divergent agents; omit `agent` for the default worker with the corresponding lens brief when a specialist is absent. Keep independent contexts. Prefer different configured model families, especially for Divergent versus Judgment, only when the roster supplies them; report weaker diversity otherwise. Reviewers may use inherited MCP tools for read-only citation checks. The brief forbids mutations; there is no task `readonly` field.# s#Each reviewer and the synthesizer name a role line in the `pstack-models\.mdc` rule and a default\. Set `model` to that line's value, or to the default if the rule or the line is missing\. Leave `model` unset when the value is `auto` or `inherit-parent`\. If the Task tool rejects a slug, use the default and say so\. If it rejects the default, use the closest valid slug of the same family from its error message\.#Resolve exact discovered agent names for the three lenses and the synthesizer. Each agent's frontmatter or `task.agentModelOverrides[]` selects the model; the task item has no `model` field.# -s#One `Task` call, `subagent_type: generalPurpose`, with `model` from the `reflect judgment, divergent, synthesizer` line \(default `[^`]+`\), agent mode \(`readonly: false`\)\.#One `task` call with one item in `tasks[]`, the required shared `context`, and an exact discovered synthesizer agent name. Its frontmatter or `task.agentModelOverrides[]` selects the model; the task item has no `model` field. The synthesizer needs full tools for citation spot-checks; there is no task `readonly` field.# +s#^One `Task` call, `subagent_type: generalPurpose`, `model` from the `reflect judgment, divergent, synthesizer` line \(default `[^`]+`\), agent mode \(`readonly: false`\)\..*Pass `references/synthesizer\.md` verbatim, with each reviewer's full output inlined where marked\. It returns an Accepted / Rejected / Backlog list\.$#Start the synthesizer in one `task` call with one item in `tasks[]` and the required shared `context`. Use an exact discovered synthesizer, or omit `agent` for the default worker with the synthesizer brief. Agent frontmatter or `task.agentModelOverrides[]` selects the model. Pass `references/synthesizer.md` verbatim, with each reviewer's full output inlined where marked. Permit read-only MCP citation checks and forbid mutations in the brief. It returns an Accepted / Rejected / Backlog list.# # Remove the old MCP-mode claims left after the task-call rewrite. s#Reviewers need MCP access for context lookups \(tickets, chat threads, observability traces referenced in the transcript\)\. Readonly strips MCPs\.## s#The synthesizer's quality check includes spot-verifying citations, which can require MCP access\. Readonly strips MCPs\.## @@ -113,7 +170,6 @@ s#Spawn one synthesizer\.#Start one synthesizer in one `task` call with one item # Inline selectors that survive into patch context are exact values, not a Markdown catch-all. s#`subagent_type: "poteto-agent"`#`agent`: `poteto-agent`#g s#Shape: one or two questions with 4-6 options each, `allow_multiple: true` for category questions\.#Shape: one or two questions with 4-6 options each. Put `multi: true` on each category question object.# -s#^1\. Spawn `Task` with `subagent_type: "Comment Sicko"`\.#1. Spawn one `task` call with one item in `tasks[]` and the required shared `context`. Use the exact discovered `comment-sicko` agent when present; otherwise omit `agent` for the default worker. Resolve the loaded `skill://no-comments` path to its plugin root and put the comment-reviewer role plus the absolute `/agents/comment-sicko.md` file pointer in its brief.# # Plan lanes and the checker name an exact discovered worker agent, never a model-map role label. s#per the \*\*swarm\*\* skill, on the `swarm workers` model \(default `grok-[^`]+`\)#per the **swarm** skill, using exact discovered worker agents. Inspect each agent's frontmatter or `task.agentModelOverrides[]` for its model# s###g @@ -227,10 +283,13 @@ s#arm a `/goal` with this exact text\.#arm a `/goal` with this exact text. Use ` ## 6. cursor-team-kit. Cursor's companion plugin -> omp's built-in tools. -# `/deslop` -> the `unslop` skill plus `omp cleanse` for diagnostics. -s#the `deslop` skill from the `cursor-team-kit` plugin \(`/deslop`\)#the `unslop` skill (`skill://unslop`) plus `omp cleanse --all` for diagnostics#g -s#Run `/deslop` from `cursor-team-kit` over the diff before commit\.#Run the `unslop` skill (`skill://unslop`) plus `omp cleanse --all` over the diff before commit. A bare `omp cleanse` opens an interactive picker and blocks.# -s#`/deslop`#the `unslop` skill (`skill://unslop`) plus `omp cleanse --all`#g +# `/deslop` -> the `unslop` skill plus the repository's own non-mutating diagnostics, scoped to the +# diff. Never `omp cleanse`: its CLI help reads "Detect and fix project diagnostics with weighted +# parallel subagents", so `--all` runs every discovered checker repo-wide and edits unrelated files. +# The gate's `runtime unscoped-cleanup` check rejects the command if it ever comes back. +s#the `deslop` skill from the `cursor-team-kit` plugin \(`/deslop`\)#the `unslop` skill (`skill://unslop`) plus non-mutating repository diagnostics scoped to the diff#g +s#Run `/deslop` from `cursor-team-kit` over the diff before commit#Run the `unslop` skill (`skill://unslop`) and non-mutating repository diagnostics scoped to the diff before commit# +s#`/deslop`#the `unslop` skill (`skill://unslop`)#g # `control-ui` / `control-cli` -> browser, computer, and named bash processes. s#`control-ui` or `control-cli` runtime verification \(from `cursor-team-kit`\)#`browser` or `computer` for UIs, or bash with a unique `name`, a `ready` check, and `proc://` state for CLIs and TUIs# s#\(`control-cli` or `control-ui` from `cursor-team-kit` as the change demands\)#(`browser` or `computer` for UIs, bash with a unique `name`, a `ready` check, and `proc://` state for CLIs and TUIs, as the change demands)# @@ -262,6 +321,15 @@ s#`create-skill`#the `authoring-a-skill` playbook#g ## 8. Transcripts. Cursor's per-project transcript directory -> omp's session store. +# Reflect's locate step names Cursor's three transcript layouts and a bash listing over them. The +# session-store reference owns root resolution and bucket encoding, so the step points there. These +# run before the path and placeholder tokens below, which would otherwise rewrite half a sentence. +s#^Before fanning out, find this conversation's transcript in the `agent-transcripts/` directory the system prompt names\..*$#Before fanning out, read `skill://omp-mechanics/references/session-store-on-omp.md`. Prefer `history://` for an agent registered in this process. Resolve this conversation's transcript directory using that reference. An explicit session-directory override is already the transcript directory; otherwise select the canonical-cwd bucket under the selected profile root. Search only that directory, never unrelated workspace chats.# +s#^ls -t /.*$#Use glob on /*.jsonl and the selected session's /**/*.jsonl sidecars. Resolve placeholders first, then inspect candidates with read.# +/^## 1\. Locate the active transcript$/,/^## 2\. Spawn three reviewers in parallel$/s#^```bash$#```text# +# `/clear` appends a durable `reset_boundary`; the raw file still holds what the model no longer saw. +s#^That covers the legacy flat \(`\.jsonl`\), current nested \(`/\.jsonl`\), and subagent \(`/subagents/\.jsonl`\) layouts\..*$#OMP stores flat `_.jsonl` root sessions and `/.jsonl` child sidecars; nested child directories carry full dotted IDs. A current file starts with a fixed-width `type:title` slot, then a `type:session` header. Legacy files may start at the header. Neither is a user message. Match the opening user prompt against the first `type:message` entry whose `message.role` is `user`, reading its `message.content` text. If no candidate matches, pass a tight digest of this session instead. When a transcript has `reset_boundary` entries, review only the active context after the latest boundary unless the user explicitly asks to review earlier history.# + s#`~/\.cursor/projects//agent-transcripts//\.jsonl`#the session store, by default `~/.omp/agent/sessions//_.jsonl`# s#where `` is the workspace path with the leading slash dropped and each "/" turned into "-" \(so `/Users/you/proj` becomes `Users-you-proj`\)#where `` is the cwd with $HOME stripped and each "/" turned into "-" (so `~/proj` becomes `-proj`)# s#Every line is one chat message\.#Every line is one JSON object. The first is a fixed-width `type:title` slot, the second a `type:session` header, and the rest messages whose roles are camelCase (`toolResult`, not `tool_result`).# @@ -354,7 +422,9 @@ s#^paths: \[#globs: [# # English verb of the same word. s#- `Read` tool calls against any#- `read` calls against any# s#- `Task` prompts that name a skill path#- `task` calls whose brief names a skill path# -s#Tool calls \(Shell, Grep, MCP, etc\.\)#Tool calls (`bash`, `grep`, MCP, etc.)# +s#([Tt])ool call(s?) \(Shell, Grep, MCP, etc\.\)#\1ool call\2 (`bash`, `grep`, MCP, etc.)# +s#a `Read` of its `SKILL\.md`#a `read` of its `SKILL.md`#g +s#a `Task` prompt naming its path#a `task` item whose brief names its path#g s#Reviewers return findings in the `Task` response body\.#Reviewers return findings in the spawn result's structured `data`, or in the delivered report when no schema was requested.# s#Use Glob to find directories and files, Grep to find key symbols, Read to understand#Use `glob` to find directories and files, `grep` to find key symbols, `read` to understand# s#Use Read, Grep, and Glob as needed\.#Use `read`, `grep`, and `glob` as needed.# @@ -500,3 +570,13 @@ s#(the system prompt names the path)#(`history://` is the documented surface # Unqualified force-push blocks the port's own rebase flow, which `pstackpolicy` explicitly permits # and `autopilot-stack` prescribes. Scope the pause the way the rest of the port scopes it. s#(force-push, delete production data, send external messages)#(force-push to a shared or trunk branch, delete production data, send external messages)# + +## 13. Cursor's `Task` tool name. omp's tool is lowercase `task`, and capitalised it names nothing. +## Last on purpose: every sentence rule above quotes the capitalised form in its left-hand side, so +## this net only ever sees what no sentence rule claimed. The gate's `cursor tool names` check +## fails on any `Task` that survives it. + +s#`Task`#`task`#g +s#the Task tool#the `task` tool#g +s#the full Task schema#the live `task` schema#g +s#every Task spawn#every `task` spawn#g diff --git a/omp-port/stale-exempt.tsv b/omp-port/stale-exempt.tsv index f736bef..a2f0ae6 100644 --- a/omp-port/stale-exempt.tsv +++ b/omp-port/stale-exempt.tsv @@ -24,13 +24,9 @@ Work like a careful, cautious, precise investigator\. superseded: upstream rewor Spawn one Task subagent that explores and explains in one superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. Once all explorers have returned, spawn one Task subagent superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. Spawn one synthesizer subagent: superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. -Spawn all explorers in a single message: superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. Launch all matching investigators in a single message so t superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. -^Launch all reviewers in a single message using the Task t forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. ^Spawn one reviewer per configured model to adversarially forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. Spawn all N subagents in one message with `run_in_backgrou forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. -One message, three `Task` calls, `subagent_type: generalPu forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. -One `Task` call, `subagent_type: generalPurpose`, with `mo forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. Reviewers need MCP access for context lookups \(tickets, c superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. The synthesizer's quality check includes spot-verifying ci superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. there is no task `readonly` field\. Use forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. @@ -44,7 +40,6 @@ agent mode \(`readonly: false`\) forward-compat net: the left side is a Cursor w ^- `readonly`: `true`$ forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. `run_in_background: true` forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. `subagent_type: "poteto-agent"` forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. -^1\. Spawn `Task` with `subagent_type: "Comment Sicko"`\. forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. ^Spawn one investigator per category that has a matching M superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. One subagent per PR, not batched, each a Cursor cloud agen superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. `claude-fable-[0-9.-]+(-(thinking-)?[a-z]+)?` forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. @@ -65,7 +60,6 @@ In a local session, a real terminal `/loop`\. In a cloud r superseded: upstream Hold the watch under `/loop` in dynamic mode\. superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. arm a `/goal` with the full program objective\. superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. arm a `/goal` with this exact text\. superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. -Run `/deslop` from `cursor-team-kit` over the diff before forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. \(`control-cli` or `control-ui` from `cursor-team-kit` as forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. \(`control-ui` or `control-cli` from `cursor-team-kit` as forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. `cursor-team-kit` publishes `control-cli` \(CLIs and TUIs\ forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. @@ -84,7 +78,6 @@ re-read this playbook from trunk with `git show origin/mai superseded: upstream Re-read the execution playbook from trunk and the armed /g superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. - `Read` tool calls against any superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. - `Task` prompts that name a skill path superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. -Tool calls \(Shell, Grep, MCP, etc\.\) superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. Use Glob to find directories and files, Grep to find key s superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. If the bottom PR is mergeable now, squash it with superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. push with `--force-with-lease` only after an `ls-remote` c superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. @@ -95,5 +88,6 @@ Recompute `frontier.json` from `gt` after every merge and superseded: upstream Use for /figure-it-out, forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. The strict `scout` definition has no MCP grant, so never a superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. an exact discovered full-access worker for citation spot-c superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. +an exact discovered (why investigators|why synthesizer|interrogate reviewers second-order net: it rewrites the output of the `model`: the `` line token rules, so raw upstream never matches it. At 4ec7ffa the whole-sentence rules for why and reflect claim the last role-line sentences first, so the token rules emit nothing for it to clean. Kept because a reworded sentence upstream falls through to those token rules, and this is what turns their role label into a capability. `readonly`: `true`#read-only posture carried in the brief forward-compat net: the Cursor wire field as it appears mid-sentence rather than as a role bullet. Upstream emits no such line today; the rule is what keeps it from reaching the tree if it ever does. \ No newline at end of file diff --git a/omp-port/test-gate-mutations.sh b/omp-port/test-gate-mutations.sh index 34519a4..a95772d 100755 --- a/omp-port/test-gate-mutations.sh +++ b/omp-port/test-gate-mutations.sh @@ -33,7 +33,9 @@ expect_failure() { if [ "$status" -eq 0 ]; then printf 'mutation tests: %s unexpectedly passed\n' "$name" fail=1 - elif ! printf '%s\n' "$output" | grep -qF "$marker"; then + # The marker has to be the label of a line that reports FAIL. Every label prints on a PASS run + # too, so a bare substring match accepted a mutation that some other check had rejected. + elif ! printf '%s\n' "$output" | awk -v m="$marker" 'substr($0, 1, length(m)) == m && $NF == "FAIL" { hit = 1 } END { exit !hit }'; then printf 'mutation tests: %s failed for the wrong reason\n%s\n' "$name" "$output" fail=1 else @@ -70,14 +72,38 @@ mutate_router_lever() { sed -i 's/task\.agentModelOverrides/task.model/g' "$FIXTURE/skills/poteto-mode/SKILL.md" } +# The claim an earlier tree shipped. omp has no `hub` tool, so naming one must fail on its own check, +# not only on the retired `op:` wording. mutate_hub_api() { - printf '\nUse `hub` `op: "list"` to enumerate workers.\n' >>"$FIXTURE/skills/pstack-omp/SKILL.md" + printf '\n`hub` is a real built-in tool on this harness, with ops `list`, `send`, and `wait`.\n' >>"$FIXTURE/skills/pstack-omp/SKILL.md" } mutate_install_root() { sed -i 's#~/\.omp/plugins/node_modules/pstack/skills#~/.agents/skills#' "$FIXTURE/skills/poteto-mode/playbooks/multi-phase-plan.md" } +# negoro26/pstack-omp#20: `omp cleanse --all` detects and fixes repo-wide, so it is no diagnostic. +mutate_unscoped_cleanup() { + printf '\nRun `omp cleanse --all` before commit.\n' >>"$FIXTURE/skills/poteto-mode/SKILL.md" +} + +mutate_general_purpose() { + printf '\nSpawn every explorer as a `generalPurpose` worker.\n' >>"$FIXTURE/skills/how/SKILL.md" +} + +# Restores the upstream wording rules.sed rewrites in the reflect reviewer templates. +mutate_task_tool() { + sed -i 's/a `task` item whose brief names its path/a `Task` prompt naming its path/' "$FIXTURE/skills/reflect/references/judgment-reviewer.md" +} + +mutate_transcript_layout() { + printf '\nFind this conversation in the `agent-transcripts/` directory the system prompt names.\n' >>"$FIXTURE/skills/reflect/SKILL.md" +} + +mutate_guide_playbook() { + rm "$FIXTURE/skills/poteto-mode/playbooks/prototype.md" +} + expect_failure 'unrelated ownership' 'setup config contract' mutate_preserve_ownership expect_failure 'per-agent ownership' 'setup config contract' mutate_override_ownership expect_failure 'alias shape' 'setup config contract' mutate_alias_shape @@ -87,6 +113,11 @@ expect_failure 'setup model lever' 'omp model levers' mutate_setup_lever expect_failure 'router model lever' 'omp model levers' mutate_router_lever expect_failure 'agent hub api' 'agent hub api' mutate_hub_api expect_failure 'install root' 'install root' mutate_install_root +expect_failure 'unscoped cleanup' 'runtime unscoped-cleanup' mutate_unscoped_cleanup +expect_failure 'generalPurpose agent' 'runtime per-call-model' mutate_general_purpose +expect_failure 'capitalised Task tool' 'cursor tool names' mutate_task_tool +expect_failure 'cursor transcript layout' 'runtime history-layout' mutate_transcript_layout +expect_failure 'guide prototype playbook' 'guide claims' mutate_guide_playbook mutate_veto_provenance() { sed -i 's/ships no `session_stop` veto/ships a session_stop veto/' "$FIXTURE/README.md" diff --git a/plugins/pstack/agents/poteto-agent.md b/plugins/pstack/agents/poteto-agent.md index 9896f92..f687cd9 100644 --- a/plugins/pstack/agents/poteto-agent.md +++ b/plugins/pstack/agents/poteto-agent.md @@ -6,4 +6,4 @@ description: Delegate subagent for code-writing and helper tasks spawned during # Poteto subagent -You are operating as poteto-mode's full agent style. The chat that receives `/skill:poteto-mode` owns the playbook, the todo list, and every Task spawn. You are one of its delegates. Read the `poteto-mode` skill's `SKILL.md` in full before doing any work, including its inline Principles index. Use the absolute path your parent gave you. With no path, before any other work, search `~/.omp/plugins/` for a file whose path ends in `skills/poteto-mode/SKILL.md`. If more than one file matches, use the copy whose `.cursor-plugin/plugin.json` has the highest `version`. Your skill catalog hides this skill, so a missing catalog entry does not mean pstack is absent. If no such file exists, stop and tell your parent. Navigate to a leaf `principle-*` skill whenever you apply that principle. +You are operating as poteto-mode's full agent style. The chat that receives `/skill:poteto-mode` owns the playbook, the todo list, and every `task` spawn. You are one of its delegates. Read the `poteto-mode` skill's `SKILL.md` in full before doing any work, including its inline Principles index. Use the absolute path your parent gave you. With no path, before any other work, search `~/.omp/plugins/` for a file whose path ends in `skills/poteto-mode/SKILL.md`. If more than one file matches, use the copy whose `.cursor-plugin/plugin.json` has the highest `version`. Your skill catalog hides this skill, so a missing catalog entry does not mean pstack is absent. If no such file exists, stop and tell your parent. Navigate to a leaf `principle-*` skill whenever you apply that principle. diff --git a/plugins/pstack/skills/arena/SKILL.md b/plugins/pstack/skills/arena/SKILL.md index 53a9aa2..f46104b 100644 --- a/plugins/pstack/skills/arena/SKILL.md +++ b/plugins/pstack/skills/arena/SKILL.md @@ -14,7 +14,7 @@ Every candidate gets the same prompt, so the prompt is the contract. 1. State the artifact each candidate is producing. 2. Derive the rubric, 3-6 concrete gradeable criteria for what success looks like on *this* task. It is the picker's tool in Phase D. Candidates only see the task. -3. Pick the runners. Resolve exact discovered agent names and inspect each agent's frontmatter or `task.agentModelOverrides[]`. Prefer distinct configured model families when the roster supplies them; otherwise keep the independent contexts and report weaker model diversity. A model override cannot create an agent. Spawn more runners when the arena covers multiple design directions. Use the same model N times when the work is generation-bound rather than judgment-sensitive. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. +3. Pick the runners. Resolve exact discovered agent names and inspect each agent's frontmatter or `task.agentModelOverrides[]`. Prefer distinct configured model families when the roster supplies them; otherwise keep the independent contexts and report weaker model diversity. A model override cannot create an agent. Spawn more runners when the arena covers multiple design directions. Use the same model N times when the work is generation-bound rather than judgment-sensitive. Use the reasoning and speed already selected by agent frontmatter or exact `task.agentModelOverrides` entries. Do not change operator configuration to satisfy a skill example. 4. Give each candidate its own output location (a git worktree where possible, otherwise `/tmp/arena-/candidate-/`), per the **separate-before-serializing-shared-state** principle skill. ## Phase B: Fan out diff --git a/plugins/pstack/skills/automate-me/SKILL.md b/plugins/pstack/skills/automate-me/SKILL.md index ff26d74..a90eb2f 100644 --- a/plugins/pstack/skills/automate-me/SKILL.md +++ b/plugins/pstack/skills/automate-me/SKILL.md @@ -28,7 +28,7 @@ Update mode changes the rest of the flow: Locate the active workspace's transcripts before fanning out. The system prompt names the session transcript tree `~/.omp/agent/sessions//*.jsonl`, with subagent sidecars at `/.jsonl`. Use only that path. Don't glob across sibling `~/.omp/agent/sessions//` buckets. That crosses workspace boundaries and reads private chats from unrelated projects. -Survey recent agent conversations within that scope for recurring patterns. Run multiple parallel subagents across slices of history (e.g. last 2-4 weeks, split into 3 slices so each has enough material). Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. Each slice mining subagent reads transcripts from the workspace-scoped path the parent provides, looks for the signals below, and returns a short structured list of patterns it saw with evidence pointers. Default signals worth hunting: +Survey recent agent conversations within that scope for recurring patterns. Run multiple parallel subagents across slices of history (e.g. last 2-4 weeks, split into 3 slices so each has enough material). Use the reasoning and speed already selected by agent frontmatter or exact `task.agentModelOverrides` entries. Do not change operator configuration to satisfy a skill example. Each slice mining subagent reads transcripts from the workspace-scoped path the parent provides, looks for the signals below, and returns a short structured list of patterns it saw with evidence pointers. Default signals worth hunting: - Response preferences (length, tone, format, "dumb it down" corrections) - Delegation habits (subagents, models, specialized workflows, parallelism) diff --git a/plugins/pstack/skills/figure-it-out/SKILL.md b/plugins/pstack/skills/figure-it-out/SKILL.md index 0d04c2e..239a680 100644 --- a/plugins/pstack/skills/figure-it-out/SKILL.md +++ b/plugins/pstack/skills/figure-it-out/SKILL.md @@ -28,7 +28,7 @@ Decompose into atomic, independently-landable units. Sequence riskiest-unknown-f - Build the verification harness before the work, with the baseline captured from the pre-change state, so the check reads as "old value vs new value". - For one-way-door design decisions, run the **architect** skill (it runs **arena**). Skip it for mechanical work whose shape is already concrete. A second arena over a settled design is over-engineering (the **laziness-protocol** principle skill). -- Decide what fans out. Parallelize only across seams, and give each worker its own worktree or branch (the **separate-before-serializing-shared-state** principle skill). Don't over-fan. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. +- Decide what fans out. Parallelize only across seams, and give each worker its own worktree or branch (the **separate-before-serializing-shared-state** principle skill). Don't over-fan. Use the reasoning and speed already selected by agent frontmatter or exact `task.agentModelOverrides` entries. Do not change operator configuration to satisfy a skill example. - Write the designed phase list down. That list is what the human reviews. Then execute the design. Add its steps to the todolist as concrete items between the Phase C and Phase D entries. Run each under the Phase C loop discipline, and weave the Phase D log through them, a row as each step lands, rather than saving the whole trail for the end. diff --git a/plugins/pstack/skills/how/SKILL.md b/plugins/pstack/skills/how/SKILL.md index 4c6f04d..16112f3 100644 --- a/plugins/pstack/skills/how/SKILL.md +++ b/plugins/pstack/skills/how/SKILL.md @@ -8,7 +8,7 @@ disable-model-invocation: true Explore the codebase to answer "how does X work?" questions. Produce architectural explanations at the level of a senior engineer onboarding onto a subsystem, enough to build a working mental model, not so much that it reads like annotated source code. -Every spawn is a Task subagent with `agent: generalPurpose` and read-only posture carried in the brief. Explorers use an exact discovered explorer agent in `~/.omp/agent/config.yml` in the `task.agentModelOverrides` entries in `~/.omp/agent/config.yml` (default your fast code model), and every explainer uses an exact discovered explainer agent in `~/.omp/agent/config.yml` (default your strongest judgment model). Each discovered agent's frontmatter or `task.agentModelOverrides[]` selects its model; the item has no `model` field. An `auto` or `inherit-parent` entry means the parent chat model, so omit that role from the map. If the Task tool rejects a slug, use the default and say so. If it rejects the default, use the closest valid slug of the same family from its error message. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. +Every delegation uses one `task` call with the required shared `context` and all independent items in `tasks[]`. Resolve exact agent names against the live roster, not configuration keys. Repository explorers use a discovered `scout`; explainers use a discovered explainer or omit `agent` for the default worker with the explainer brief. Each item's `task` is standalone and carries its angle and prompt template. All work is read-only. Models, reasoning, and speed come from agent frontmatter or `task.agentModelOverrides[]`, never per-call model fields or family fallbacks. Overrides cannot create agents. ## 1. Assess complexity @@ -21,7 +21,7 @@ When in doubt, take the simple path. ## 2. Explore (complex only) -Decompose the question into 2 to 4 angles, each a distinct slice of the subsystem. Spawn all explorers in a single message, each with `references/explorer-prompt.md` and its angle filled in. +Decompose the question into 2 to 4 angles, each a distinct slice of the subsystem. Start all explorers in one `task` call with the required shared `context` and all independent items in `tasks[]`, each with `references/explorer-prompt.md` and its angle filled in. ## 3. Synthesize (complex only) diff --git a/plugins/pstack/skills/interrogate/SKILL.md b/plugins/pstack/skills/interrogate/SKILL.md index ac1cb54..3ed96f8 100644 --- a/plugins/pstack/skills/interrogate/SKILL.md +++ b/plugins/pstack/skills/interrogate/SKILL.md @@ -24,18 +24,18 @@ Before spawning reviewers, state the intent in one clear paragraph from the user ## Step 3, Spawn Reviewers -Launch all reviewers in a single message with the Task tool, one per configured entry in `~/.omp/agent/config.yml`, labeled Reviewer A, B, and so on to match the entry count. If the rule or that line is missing, use the table defaults. +Launch all reviewers in one `task` call with the required shared `context` and all items in `tasks[]`. Resolve exact discovered reviewer agents from the live roster, one per configured slot, labeled Reviewer A, B, and so on. Omit `agent` for the default worker with the reviewer brief when a specialist is absent. Report unavailable model-family diversity; never invent an agent from an override key. -| Subagent | Default model | +| Reviewer slot | Runtime agent | |----------|---------------| -| Reviewer A | your strongest judgment model | -| Reviewer B | your fast code model | +| Reviewer A | discovered reviewer agent or default worker | +| Reviewer B | independently configured reviewer agent or default worker; report unavailable diversity | -Each reviewer gets `agent: generalPurpose`, read-only posture carried in the brief, and `model` set to its configured entry, or its table default when there is no configured line. Omit `model` for an `auto` or `inherit-parent` entry, so that reviewer runs on the parent model. +Each task item uses an exact discovered reviewer agent or omits `agent` for the default worker with the same reviewer brief. Carry read-only posture in the brief, not a `readonly` field. Agent frontmatter or `task.agentModelOverrides[]` selects the model; do not pass `model`. Shared `context` carries the intent and rubric, while each item carries the standalone review task. If the requested exact reviewer is absent, use another discovered reviewer with the same brief and report the missing model diversity. A model override cannot create an agent. -Read `references/reviewer-prompt.md` and fill in the same template for every reviewer with the stated intent, the diff or file contents, the rubric from `references/rubric.md`, and the code-quality lens from `references/code-quality-review.md`. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. +Read `references/reviewer-prompt.md` and fill in the same template for every reviewer with the stated intent, the diff or file contents, the rubric from `references/rubric.md`, and the code-quality lens from `references/code-quality-review.md`. Use the reasoning and speed already selected by agent frontmatter or exact `task.agentModelOverrides` entries. Do not change operator configuration to satisfy a skill example. ## Step 4, Synthesize diff --git a/plugins/pstack/skills/no-comments/SKILL.md b/plugins/pstack/skills/no-comments/SKILL.md index 6d3a2a8..d3ac3ea 100644 --- a/plugins/pstack/skills/no-comments/SKILL.md +++ b/plugins/pstack/skills/no-comments/SKILL.md @@ -16,7 +16,7 @@ Use the caller's files or diff. Otherwise use the current diff against the base ## Steps -1. Spawn `Task` with `agent`: `comment-sicko`. Pass the scope. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. Do not restate its rules. +1. Start one `task` call with one item in `tasks[]` and the required shared `context`. Use the exact discovered `comment-sicko` agent when present; otherwise omit `agent` for the default worker. Resolve the loaded `skill://no-comments` path to its plugin root and put the comment-reviewer role, scope, and absolute `/agents/comment-sicko.md` pointer in the brief. Agent frontmatter or its exact override selects reasoning and speed. Do not restate the reviewer's rules. 2. Inspect its report and diff. Reject application-code edits, scope escapes, exception-protected deletions, misstated `MUST KILL` reasons, and flags that treat kept intentional code as guilty. Reshape flags on our-code surprises stay actionable. Do not restore those comments. A keep survives only with proof it is about something we cannot change. Audit missed scoped lint and TypeScript suppressions. Correctness or safety suppressions stay actionable `MUST KILL`s. Restore deletions only with exact exceptions and scoped proof. Before accepting thin `IMPORTANT` or `do not remove` kills or keeps, run `/skill:how` or `/skill:why` on their symbol. If a kill is ambiguous, do not restore. If a keep is refuted or still ambiguous, delete it. Revert and rerun one rejected report with the failure named. Reject a second, report it open, and fail `/skill:no-comments`. 3. Fix trivial accepted flags directly by deleting a dead path, dropping a parameter, or using the real API. If any fix needs a shape, run `/skill:architect` once for the accepted set and surrounding code. Stop at the sketch. Architect shapes. Step 4 implements. 4. Implement the smallest root-cause fix in scope. Remove every named workaround. If the root cause is out of scope, land the smallest in-scope fix and report the rest open. The **principle-fix-root-causes** and **principle-redesign-from-first-principles** skills guide intent only. Neither authorizes widening the fence nor fixing instances outside it. Never bolt on symptom guards. diff --git a/plugins/pstack/skills/poteto-help/SKILL.md b/plugins/pstack/skills/poteto-help/SKILL.md index 8844afa..b831ee8 100644 --- a/plugins/pstack/skills/poteto-help/SKILL.md +++ b/plugins/pstack/skills/poteto-help/SKILL.md @@ -8,9 +8,9 @@ disable-model-invocation: true Answer the user's question about pstack, hand them a prompt they can send, and link the file the answer came from. For a help question, don't start the work. The user asked how, and a pstack run spends real tokens, so let them send the prompt. -A message that asks for work, such as "use pstack to fix this bug", is not a help question. Read [`poteto-mode`](../poteto-mode/SKILL.md), do the work under it, and mention once that a Custom Mode keeps it on. +A message that asks for work, such as "use pstack to fix this bug", is not a help question. Read [`poteto-mode`](../poteto-mode/SKILL.md), do the work under it, and mention once that `/poteto-mode on` keeps it pinned. -This file maps questions to the skills and guide pages that hold the answers. Those files own the details. Read the file you route to before you quote it, and trust it when it disagrees with this map. The links here point into the installed plugin, which the user may not be able to open, so give the user the file's public copy: `https://github.com/cursor/plugins/blob/main/pstack/` followed by its path. +This file maps questions to the skills and guide pages that hold the answers. Those files own the details. Read the file you route to before you quote it, and trust it when it disagrees with this map. Link installed skill and runtime files directly. For a public copy of ported behavior, use `https://github.com/negoro26/pstack-omp/blob/main/plugins/pstack/` followed by the installed plugin-relative path. Guide URLs point to upstream for workflow background only; use this port's README and runtime skills for OMP commands. ## Find out what they need @@ -24,40 +24,40 @@ Infer the need from the message and the conversation. A named situation, such as Check the state that changes the answer, and mention it only when it does: -- No `task.agentModelOverrides` in `~/.omp/agent/config.yml` means `/skill:setup-pstack` hasn't run for this user, so every role uses its default model. +- No exact override for a role means its loaded agent frontmatter or the parent chat model selects its model. Absence of an override does not prove setup was never run. - No `verify-*` skill or other app harness in the project means agents have no scripted way to drive the app. Mention `/skill:create-verification-skill` when the question is about proving a change works. - No formal verification skill in the project means no property is checked past what tests reach. Mention `/skill:create-formal-verification-skill` when the question is about a race, an invariant, or another case tests rarely hit. When one exists and the code it covers changed after the commit it records as last in sync, mention `/skill:maintain-formal-verification-skill` if the question touches that code. -When the model rule is missing and it matters, ask whether the user wants to pick a model for each role and a reasoning budget now. It matters when the user is new, the question is about setup or cost, or the answer depends on which models run. Ask at most once per chat. If the need is also unclear, ask both questions together. Offer two choices: +When required role overrides are missing and model selection matters, ask whether the user wants to pick a model for each role and a reasoning budget now. It matters when the user is new, the question is about setup or cost, or the answer depends on which models run. Ask at most once per chat. If the need is also unclear, ask both questions together. Offer two choices: - Now: give them `/skill:setup-pstack` to type, and answer their question too. -- Later: answer their question, and add one line saying every role keeps its default model until they run `/skill:setup-pstack`. +- Later: answer their question, and say agent frontmatter or parent inheritance remains in effect until they configure exact role overrides. ## Get set up -1. Install with `/add-plugin pstack` in chat, or from Customize in the sidebar. -2. Run [`/skill:setup-pstack`](../setup-pstack/SKILL.md). It asks for a reasoning budget, maps a model to each role, and writes a rule. The rule applies to new chats. Then it offers once each to generate a verification skill and a formal verification skill for the project, unless the project already has one. +1. Install with `/marketplace add negoro26/pstack-omp`, then `/marketplace install pstack@pstack-omp`. +2. Run [`/skill:setup-pstack`](../setup-pstack/SKILL.md). It selects models and a reasoning budget for exact discovered agents, writing three pstack-owned `modelRoles` aliases and `task.agentModelOverrides` entries in `~/.omp/agent/config.yml`. It does not create missing agents or select the chat model. Read the skill for activation and optional verification-skill setup. 3. Start a real task with `/skill:poteto-mode`, a goal, and a check that can pass or fail. -Installing changes nothing until the user invokes a skill. Only `/skill:setup-pstack` loads from the user's words. The [README](../../README.md) and [guide page 1](../../docs/guide/01-setup.md) have the details. Offer to word their first prompt with them, per [`references/prompting.md`](references/prompting.md). +Skills run when invoked or routed. The installed extension also provides the session pin commands. `setup-pstack` is available for model-configuration requests. The [README](../../README.md) and [guide page 1](https://github.com/cursor/plugins/blob/main/pstack/docs/guide/01-setup.md) have the details. Offer to word their first prompt with them, per [`references/prompting.md`](references/prompting.md). -If cost is the worry, say where the tokens go and how to spend fewer. pstack spends extra tokens on subagents and review panels. Rerun `/skill:setup-pstack` and pick a smaller budget or cheaper models. A role set to `auto` or `inherit-parent` runs on the chat's model, which saves tokens when the chat runs on Auto or a cheaper model. A shorter panel list runs fewer subagents, one for each entry. Save `/skill:poteto-mode` for work that needs rigor. +If cost is the worry, explain that extra workers and review panels spend more tokens. Rerun `/skill:setup-pstack` to choose a smaller reasoning budget or cheaper available models. An agent without a model override uses its frontmatter or inherits the chat model. Fewer configured reviewer slots mean fewer subagents. Never write `auto` or `inherit-parent` as an OMP model selector. Save `/skill:poteto-mode` for work that needs rigor. pstack installs in omp with `/marketplace add negoro26/pstack-omp`, then `/marketplace install pstack@pstack-omp`. Invoke skills as `/skill:`, such as `/skill:poteto-mode`. Read `skill://pstack-omp` for the live task and runtime contract and `skill://omp-mechanics` for pstack-specific OMP differences. -Other tools can read the skills, because they use the Agent Skills format. But most workflow skills, including `/skill:poteto-mode`, `/skill:how`, `/skill:why`, and `/skill:teach`, spawn Cursor subagents with per-role models, and Custom Modes and `/loop` are Cursor features, so those parts may not work there. +The engineering workflows come from upstream. `skill://pstack-omp` maps worker execution and lifecycle to OMP; `skill://setup-pstack` configures OMP models, and the mode extension preserves the session pin. ## Start a task with `/skill:poteto-mode` -`/skill:poteto-mode` matches the task to a playbook, copies the playbook's steps into the todo list, and runs the other skills as the steps need them. A step it skips stays in the list as `skip: `. A good prompt states the goal and how to tell it's done. It doesn't list skills, because a hand-written sequence tends to drop or reorder steps the playbook would keep. Read [`references/prompting.md`](references/prompting.md) before you help word one. [Guide page 2](../../docs/guide/02-poteto-mode.md) has examples. +`/skill:poteto-mode` matches the task to a playbook, copies the playbook's steps into the todo list, and runs the other skills as the steps need them. A step it skips stays in the list as `skip: `. A good prompt states the goal and how to tell it's done. It doesn't list skills, because a hand-written sequence tends to drop or reorder steps the playbook would keep. Read [`references/prompting.md`](references/prompting.md) before you help word one. [Guide page 2](https://github.com/cursor/plugins/blob/main/pstack/docs/guide/02-poteto-mode.md) has examples. -Whether `/skill:poteto-mode` stays on depends on how the user starts it: +Choose whether to invoke the router once or pin it for the session: -- Enter on `/skill:poteto-mode` attaches the skill to one message. It fades as the chat moves on. -- Option+Enter on Mac or Alt+Enter on Windows, or Use as Mode from the skill entry, makes it a Custom Mode. It stays in context every turn until the user exits the mode, and it stays out of casual turns. -- Cursor's docs list Custom Modes in the Agents Window and the CLI. Elsewhere, start each new task with `/skill:poteto-mode`. +- `/skill:poteto-mode` invokes the router for a task; it does not enable the persistent pin. +- `/poteto-mode on` pins the router for every turn, `/poteto-mode off` unpins it, and `/poteto-mode status` reports the current pin. These are the installed extension's OMP commands, not Cursor's Custom Mode UI. +- The extension registers Alt+Shift+T to toggle the pin when the terminal delivers that key. For print-mode runs use `omp --poteto -p ''`. Do not claim Cursor's platform shortcuts work here. -Link [Cursor's skills docs](https://cursor.com/docs/skills) when this comes up. Mid-chat, "new task" makes the mode match a fresh playbook. `/skill:poteto-mode` already uses `poteto-agent` for the subagents its playbook steps spawn. To get the same style from a subagent of your own, spawn it with `agent`: `poteto-agent`. +Link the installed [OMP README](../../README.md) for pin commands and [runtime mapping](../pstack-omp/SKILL.md) for delegation. Mid-chat, "new task" makes the router match a fresh playbook. Resolve any helper against the live agent roster; use `poteto-agent` only when loaded, otherwise follow the runtime mapping's role and default-worker routing. ## Pick a skill @@ -112,8 +112,8 @@ Close calls: Not in pstack: -- the `unslop` skill (`skill://unslop`) plus `omp cleanse --all`, bash with a unique `name`, a `ready` check, and `proc://` state, and the `browser` eval prelude ship in the omp's built-in tools plugin. -- `/loop` and `/create-skill` are Cursor built-ins. +- Prose cleanup uses `skill://unslop`. Diagnostics use the project's documented read-only checks, with LSP diagnostics only when a live server is configured. OMP exposes browser interaction through its eval prelude and CLI interaction through named bash services and `proc://`. Do not run `omp cleanse` as a diagnostic check. +- `/loop` is an OMP runtime command when exposed. Skill authoring uses the [authoring playbook](../poteto-mode/playbooks/authoring-a-skill.md); there is no assumed Cursor `/create-skill` command. - pstack has no `/orchestrate` skill. Orchestrate is a `/skill:poteto-mode` playbook. If the slash menu shows `/orchestrate`, another plugin provides it. ## Playbooks and principles @@ -127,26 +127,26 @@ Playbooks are step lists inside `/skill:poteto-mode`, not skills, so they have n - "full autopilot on this queue" runs Autopilot-full. "stack them, don't ship" runs Autopilot-stack. - "run the eval playbook" runs Eval. -Without `/skill:poteto-mode`, a phrase such as "babysit this pr" can start Cursor's own skill for the same job instead. The Playbooks section of [`poteto-mode`](../poteto-mode/SKILL.md) lists every playbook and when it applies. [Guide page 6](../../docs/guide/06-verify-and-ship.md) covers opening, babysitting, and landing a PR. +Without `/skill:poteto-mode`, the runtime may route a phrase using other enabled skills. Invoke the router explicitly to select pstack's playbook. The Playbooks section of [`poteto-mode`](../poteto-mode/SKILL.md) lists every playbook and when it applies. [Guide page 6](https://github.com/cursor/plugins/blob/main/pstack/docs/guide/06-verify-and-ship.md) gives upstream workflow examples, not OMP runtime commands. -pstack has no planning skill. Cursor's Plan Mode works alongside it. For work that spans phases or stacked PRs, asking `/skill:poteto-mode` for a plan runs the [Multi-phase plan playbook](../poteto-mode/playbooks/multi-phase-plan.md), which writes the plan and doesn't implement it. For a design question, the Prototype playbook or `/skill:architect` settles it in code first. +pstack has no planning skill. For work that spans phases or stacked PRs, asking `/skill:poteto-mode` for a plan runs the [Multi-phase plan playbook](../poteto-mode/playbooks/multi-phase-plan.md), which writes the plan and doesn't implement it. For a design question, the Prototype playbook or `/skill:architect` settles it in code first. -Principles are one-rule skills that `/skill:poteto-mode` reads and cites in its replies. The user rarely invokes one. They steer with the names instead, as in "apply prove it works. show me the real output." Typing `/principle-` still loads one on demand. [Guide page 8](../../docs/guide/08-principles.md) lists them. +Principles are one-rule skills that `/skill:poteto-mode` reads and cites in its replies. The user rarely invokes one. They steer with the names instead, as in "apply prove it works. show me the real output." Typing `/skill:principle-` loads one on demand. [Guide page 8](https://github.com/cursor/plugins/blob/main/pstack/docs/guide/08-principles.md) lists them. ## Fix a run that went wrong | Symptom | Fix | |---|---| -| The mode stopped applying after a few turns | It was started with Enter. Start it as a Custom Mode, or start each task with `/skill:poteto-mode`. | +| The mode stopped applying after a few turns | Check `/poteto-mode status`. Use `/poteto-mode on` to pin it, or invoke `/skill:poteto-mode` for each task. | | A question got treated as the next step of the last task | Say "new task", or say the turn doesn't need the mode. | -| A new model choice had no effect | The rule from `/skill:setup-pstack` applies to new chats. Start one. | +| A new model choice had no effect | Confirm the loaded agent name and its exact `task.agentModelOverrides` entry. Follow `/skill:setup-pstack` activation guidance; an override cannot create an agent. | | Runs cost more than expected | See the cost paragraph under Get set up. | -| A skill didn't load on its own | Only `/skill:setup-pstack` loads from the user's words. The others load when the user types them or when `/skill:poteto-mode` runs them, and it doesn't run every skill. | -| Parallel agents overwrote each other | Give each agent its own worktree, or run them as isolated subagents, which each get their own machine. | -| An overnight run moved but finished nothing | `/loop` needs a check that can pass or fail, not a duration. See [guide page 7](../../docs/guide/07-overnight.md). | +| A skill didn't load on its own | Invoke `/skill:` explicitly or let the router load it. Only skills enabled for model invocation appear in the per-turn index. | +| Parallel agents overwrote each other | Give each agent its own worktree, or run them as isolated subagents, which get private worktrees, not separate machines. | +| An overnight run moved but finished nothing | `/loop` needs a check that can pass or fail, not a duration. See [guide page 7](https://github.com/cursor/plugins/blob/main/pstack/docs/guide/07-overnight.md). | | The reply claims success from a green build | Ask for the real command, flow, stored value, or profile. That's the prove-it-works principle. | -For a run that drifts, [`references/prompting.md`](references/prompting.md) has one-line steers. [Guide page 10](../../docs/guide/10-recipes-and-pitfalls.md) has more pitfalls and the recipes worth copying. +For a run that drifts, [`references/prompting.md`](references/prompting.md) has one-line steers. [Guide page 10](https://github.com/cursor/plugins/blob/main/pstack/docs/guide/10-recipes-and-pitfalls.md) has more pitfalls and the recipes worth copying. ## Make pstack my own @@ -155,7 +155,7 @@ For a run that drifts, [`references/prompting.md`](references/prompting.md) has - `/poteto-mode write a skill for ` runs the authoring playbook. The eval playbook tests a skill change blind. - Fix a misbehaving skill in its own PR, not inside the feature work where it went wrong. -[Guide page 9](../../docs/guide/09-make-it-yours.md) covers each of these. +[Guide page 9](https://github.com/cursor/plugins/blob/main/pstack/docs/guide/09-make-it-yours.md) covers each of these. ## Reply diff --git a/plugins/pstack/skills/poteto-mode/SKILL.md b/plugins/pstack/skills/poteto-mode/SKILL.md index 9af77fb..8051d0d 100644 --- a/plugins/pstack/skills/poteto-mode/SKILL.md +++ b/plugins/pstack/skills/poteto-mode/SKILL.md @@ -27,7 +27,7 @@ Remaining triggers: - Nontrivial multi-step → write the throughput checkpoint (Feature step 3). - Any prose surface, your reply to the user included → the **unslop** skill. Write the reply per **Writing the reply**. Agent-facing prose also follows the **authoring-a-skill** playbook (`skill://poteto-mode/playbooks/authoring-a-skill.md`). - Docs, RFCs, readmes, PR descriptions, or commit messages → the **technical-writing** skill (`/skill:technical-writing`). -- Before commit → the `unslop` skill (`skill://unslop`) plus `omp cleanse --all` for diagnostics. +- Before commit → the `unslop` skill (`skill://unslop`) plus non-mutating repository diagnostics scoped to the diff. - Before review → the **no-comments** skill (`/skill:no-comments`). - Shipping UI / IDE / CLI → the matching control skill. omp provides the levers directly. Bash with a unique `name`, `ready` checks, and `proc://` state drives CLIs and TUIs, the `browser` eval prelude drives browser, Electron, and web UIs over CDP, and the `computer` prelude drives native desktop. Both preludes are code in an `eval` cell and neither is a tool with its own schema. For bug fixes, reproduce first on the same surface yourself. Hand to the user only under the narrow Bug fix step 1 exception. - Running a benchmark, measuring perf yourself, or reporting a speedup or regression you measured → the **benchmark-checklist** skill before you report or act on the number. @@ -98,7 +98,7 @@ Batch genuinely independent work when supported. Give each participant a standal Model selection belongs to `task.agentModelOverrides` and agent frontmatter, not to routed-skill task fields. -**Match the model to the job.** Before each spawn, start from the role's entry in `~/.omp/agent/config.yml`, or its default and pick the reasoning level and speed the job needs. The `/skill:setup-pstack` budget sets each role's starting level, xhigh by default. Raise it one step, up to max, for the hardest or subtlest work. Lower it one step for judgment and two or more steps for routine work. Use fast mode only when the job is urgent, meaning CI or trunk is red or a person is blocked on the result right now. Otherwise use standard speed, the same slug without `-fast`. Verification is the exception and stays on the swarm role's own entry and speed. Change only the effort level and whether the slug is fast, and keep the family the role names, so a diverse panel stays diverse. If the family has no slug at that level and speed, use its nearest slug. A role on `auto` or `inherit-parent` stays on the parent model. If the Task tool rejects the adjusted slug, do not try another adjusted slug. Use the role's entry in `~/.omp/agent/config.yml`, or its default and say so. +**Match the role to the job.** Resolve the required capability against the live agent roster and use that agent's configured reasoning and speed. Pass a per-spawn `effort` only when the live task schema exposes it. Agent frontmatter or `task.agentModelOverrides[]` selects the model. Do not adjust a per-call slug, retry a family default, or change operator configuration to satisfy an example. Report unavailable diversity honestly. You own every subagent's work. Review the diff and write your own summary, don't pass through what it said. A second opinion is the same prompt against a different model. Agreement is high-signal. diff --git a/plugins/pstack/skills/poteto-mode/playbooks/autopilot-full.md b/plugins/pstack/skills/poteto-mode/playbooks/autopilot-full.md index 153906e..95f2008 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/autopilot-full.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/autopilot-full.md @@ -7,7 +7,7 @@ A verdict covers one patch and green CI covers one head. Step 4 runs a full swar 1. **The operator's gates come first.** The grant covers only the queue's items, so items the operator names as theirs and PRs outside the queue stop at merge-ready. A request to state the plan is not a go. 2. **Spawn one owner in one `task` call with one item in `tasks[]` and the required shared `context` per PR, and keep its work where a replacement can find it.** Resolve the forge once for the program. GitHub CLI (`gh`) is the default. If `command -v origin` succeeds and Origin can resolve the repository, use `origin pr` for every PR operation. Otherwise stay on `gh` and record the fallback. Never require Graphite (`gt`). Each owner is a Cursor isolated subagent that builds, opens its PR per `playbooks/opening-a-pr.md`, proves the change on the real artifact, babysits to green per `playbooks/babysit.md`, and merges once steps 4 and 5 clear it. - Open the PR early so CI and bots run alongside the work. The code is ready only after the slop-strip (the `unslop` skill (`skill://unslop`) plus `omp cleanse --all` for diagnostics) and the `/skill:no-comments` pass, so the full swarm runs on finished code. Then rebase onto current trunk and report the code-ready head before self-proof, so verification runs on the base the patch lands on. Report every later head, and merge-ready once self-proof, CI, and babysit are done. Fix rounds stay on that base unless trunk conflicts or breaks CI, because each rebase restarts CI. Run the repo's checks for the touched paths on the committed head before the code-ready push and before a later push that needs a verifier. + Open the PR early so CI and bots run alongside the work. The code is ready only after the slop-strip (the `unslop` skill (`skill://unslop`) plus non-mutating repository diagnostics scoped to the diff) and the `/skill:no-comments` pass, so the full swarm runs on finished code. Then rebase onto current trunk and report the code-ready head before self-proof, so verification runs on the base the patch lands on. Report every later head, and merge-ready once self-proof, CI, and babysit are done. Fix rounds stay on that base unless trunk conflicts or breaks CI, because each rebase restarts CI. Run the repo's checks for the touched paths on the committed head before the code-ready push and before a later push that needs a verifier. Assume the VM dies. Soon after starting, push and start a decision trail in the root agent's store (path in the system prompt) per the **show-me-your-work** skill, then push after every unit worth keeping. Whoever spawns a subagent records its id, state, and a stuck-after runtime from past runs of its kind in that store, so the root can probe it if the parent dies. Force-push only your own branch, with `--force-with-lease` pinned to the SHA you last pushed or took over, and on any push you did not make, stop and report to the root. 3. **Run owners in parallel off trunk, never stacked,** so each PR lands on its own. diff --git a/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md b/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md index ae9af5c..31dfab0 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md @@ -57,7 +57,7 @@ Tests alone are not sufficient verification. A PR is verified only when its unit - [ ] Resolve the forge once. Default to `gh`; if `command -v origin` succeeds and Origin can resolve the repository, use `origin pr` for every PR operation. Record any fallback to `gh`. Never require `gt`. - [ ] Open the PR ready, never draft, per **Opening a PR**. Use the run's built-in PR tool when it has one, else `origin pr create --status open --base ` or `gh pr create --base ` according to the resolved forge. A stack child targets its parent branch. - [ ] Run the repo's lint and typecheck once before the PR-facing push. Push with hooks on. -- [ ] Run the `unslop` skill (`skill://unslop`) plus `omp cleanse --all` before each commit and `/skill:no-comments` before review. +- [ ] Run the `unslop` skill (`skill://unslop`) before each commit and `/skill:no-comments` before review. - [ ] Triage every Bugbot and security-reviewer comment per `../references/bugbot-triage.md`. - [ ] Rebase onto current trunk before the code-ready report and babysit. Keep that merge base in fix rounds. Rebase again only at merge prep, on a `git merge-tree` conflict with trunk, or on a CI failure that comes from a change on trunk. diff --git a/plugins/pstack/skills/poteto-mode/playbooks/opening-a-pr.md b/plugins/pstack/skills/poteto-mode/playbooks/opening-a-pr.md index e6b1336..8139b25 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/opening-a-pr.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/opening-a-pr.md @@ -6,7 +6,7 @@ Invoked at the end of every other playbook. **Commits.** Commit liberally. Rebase into small, ordered commits before opening PRs. Each commit is a future PR: landable, ordered to tell the story. Amend when the fix belongs in a just-made commit. New commit when separable. Commit bodies don't restate the subject. -**PRs.** Run the `unslop` skill (`skill://unslop`) plus `omp cleanse --all` from omp's built-in tools over the diff before commit and `/skill:no-comments` before review. Write every PR title, description, and commit body with `/skill:technical-writing` (every layer except Diátaxis, and apply the STE vocabulary rules: one word per action, keep articles, a plain verb over `-ing` where one works), then apply `/skill:unslop`. +**PRs.** Run the `unslop` skill (`skill://unslop`) and non-mutating repository diagnostics scoped to the diff before commit and `/skill:no-comments` before review. Write every PR title, description, and commit body with `/skill:technical-writing` (every layer except Diátaxis, and apply the STE vocabulary rules: one word per action, keep articles, a plain verb over `-ing` where one works), then apply `/skill:unslop`. **Titles.** Conventional Commits `type(scope): subject`, no trailing period. Type such as `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, or `perf`. Scope is the area, such as `pstack` or `poteto-mode`. Subject is short, imperative, and names a real symbol when one carries the change, for example `fix(pstack): retarget opening-a-pr babysit trigger`. @@ -31,4 +31,4 @@ After the sections, attach videos or screenshots when they prove a claim. Full S **Babysit.** Opening a PR does not start a babysit. Post the URL and keep building. Finish the phase or stack first. Run a separate babysit pass only when the user asks for one after the whole stack exists. A babysit for each new PR stalls the build and spends checks on commits later waves restart. Push back when feedback drifts from intent. -A subagent that opens a PR runs `interrogate`, the `unslop` skill (`skill://unslop`) plus `omp cleanse --all`, and `/skill:no-comments`, and posts the URL. It returns to the parent without babysitting, unless it is an Autopilot-full or Autopilot-stack owner. That owner's brief assigns the babysit loop and is the ask `playbooks/babysit.md` waits for. The owner starts the loop after its code-ready report, reports merge-ready or STACK-READY per its playbook, and is exempt from the rules here and in `playbooks/babysit.md` that hold babysitting until a whole stack is built. +A subagent that opens a PR runs `interrogate`, the `unslop` skill (`skill://unslop`), and `/skill:no-comments`, and posts the URL. It returns to the parent without babysitting, unless it is an Autopilot-full or Autopilot-stack owner. That owner's brief assigns the babysit loop and is the ask `playbooks/babysit.md` waits for. The owner starts the loop after its code-ready report, reports merge-ready or STACK-READY per its playbook, and is exempt from the rules here and in `playbooks/babysit.md` that hold babysitting until a whole stack is built. diff --git a/plugins/pstack/skills/poteto-mode/playbooks/orchestrate.md b/plugins/pstack/skills/poteto-mode/playbooks/orchestrate.md index 29c714e..b3663bd 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/orchestrate.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/orchestrate.md @@ -12,8 +12,8 @@ Three rules carry the rest. #### Roles and placement -- **Coordinator (this chat).** Local. Frames, authors briefs, drains the inbox, owns the human report, makes judgment calls. It never authors or edits code. Conflicted merges, chain rebases, and code changes are always tasks. Mechanically landing a verified unit (fast-forward or clean cherry-pick of a worker's commit, then push to that unit's own branch) is bookkeeping the coordinator may do itself on repos where local git is cheap. Merging, or pushing to trunk, needs an operator landing grant recorded in the standing orders, exactly like any other irreversible write. Queueing finished work behind an idle stacker is how a deadline harvests nothing. The loop is agentic end to end. Agents are spawned and drained only through the Task tool, and resumed only through `write agent://` messaging. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. State reads and writes go through `scripts/orch/orch.ts` at drain points, one command in and one line out. The CLI never spawns, waits, or wakes anything. -- **Sub-coordinator.** Always local, durable, one per track, and only when the program exceeds what one coordinator's drains can manage. A track the coordinator can drain itself needs no middle layer. Each nested layer re-pays a full orientation preamble, and a blocking sub-coordinator hides its children while the parent idles. Owns its track's units and boards, authors its workers' briefs, spawns its own workers and verifiers (nesting works to depth 3, and a nested spawn has the full Task schema including `isolated`). Rolls up aggregates at wave boundaries. Never forwards raw child reports. Cap in-flight children at what one drain can process, roughly ten, as a rolling window. Never as blocking batches, which cost the slowest child of every batch. +- **Coordinator (this chat).** Local. Frames, authors briefs, drains the inbox, owns the human report, makes judgment calls. It never authors or edits code. Conflicted merges, chain rebases, and code changes are always tasks. Mechanically landing a verified unit (fast-forward or clean cherry-pick of a worker's commit, then push to that unit's own branch) is bookkeeping the coordinator may do itself on repos where local git is cheap. Merging, or pushing to trunk, needs an operator landing grant recorded in the standing orders, exactly like any other irreversible write. Queueing finished work behind an idle stacker is how a deadline harvests nothing. The loop is agentic end to end. Agents are spawned and drained only through the `task` tool, and resumed only through `write agent://` messaging. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. State reads and writes go through `scripts/orch/orch.ts` at drain points, one command in and one line out. The CLI never spawns, waits, or wakes anything. +- **Sub-coordinator.** Always local, durable, one per track, and only when the program exceeds what one coordinator's drains can manage. A track the coordinator can drain itself needs no middle layer. Each nested layer re-pays a full orientation preamble, and a blocking sub-coordinator hides its children while the parent idles. Owns its track's units and boards, authors its workers' briefs, spawns its own workers and verifiers (nesting works to depth 3, and a nested spawn has the live `task` schema including `isolated`). Rolls up aggregates at wave boundaries. Never forwards raw child reports. Cap in-flight children at what one drain can process, roughly ten, as a rolling window. Never as blocking batches, which cost the slowest child of every batch. - **Worker / verifier.** Always `isolated: true` unless the task needs this machine: `browser` or `computer` for UIs, or bash with a unique `name`, a `ready` check, and `proc://` state for CLIs and TUIs. Reading local transcripts under `~/.omp/agent/sessions//`. Simulators and local IDE state. Auth that exists only here. An isolated worker runs on this machine in its own worktree with no conversation history, so its brief inlines what it needs or points at absolute paths. Prefer fewer, broader workers. One writer per worktree or branch (principle-separate-before-serializing-shared-state). Run a unit's verifier on a different model family from its worker. Depth stays at coordinator, track, worker. Author the track decomposition per project (build, landing, and verification are common cuts, not a required shape). Hard-coded swarm trees were tried and parked as too rigid. diff --git a/plugins/pstack/skills/pstack-omp/SKILL.md b/plugins/pstack/skills/pstack-omp/SKILL.md index f2c88de..7ed49c5 100644 --- a/plugins/pstack/skills/pstack-omp/SKILL.md +++ b/plugins/pstack/skills/pstack-omp/SKILL.md @@ -11,7 +11,7 @@ description: "Translate poteto-mode roles and lifecycle protocols to the live OM Inspect the tools and schemas exposed in this session, not a version number or a remembered roster. -- With `task`, use the task contract below. Only pass fields actually exposed. Read and steer workers through the runtime's own resources, `agent://` and `history://`, and watch background processes through `proc://`. `hub` is a real built-in tool on this harness — sibling messaging, settled-job inspection, and supervised services — with ops `list`, `send`, `inbox`, `wait`, `cancel`, `jobs`, `start`, `logs`, `stop`. It is registered only when tool names are not restricted and IRC is enabled, so it is absent in a restricted or headless session. Reach for it when it is present, especially to have siblings coordinate before editing shared files and to hold a supervised service; where it is absent, `agent://`, `history://`, `proc://`, and a named `bash` process cover the same ground. Do not assume either is available without checking. +- With `task`, use the task contract below. Only pass fields actually exposed. Read and steer workers through the runtime's own resources, `agent://` and `history://`, and watch background processes through `proc://`. Coordinate with the `wait` tool, `read proc://`, `write proc:///kill`, and `write agent://` when the session exposes them. - Without `task`, when `vibe_spawn`, `vibe_send`, `vibe_wait`, `vibe_list`, and `vibe_kill` are exposed, use the vibe contract below. Do not try to enable task or escape director restrictions. - With neither surface, do bounded work directly only when the current mode permits it. If independent review or unavailable execution is required, report the exact missing capability. Never claim a panel ran locally as one session. @@ -63,7 +63,7 @@ The parent owns its plan tracker, and a worker has it stripped except when the s Give concurrent writers disjoint paths or separate worktrees. If `isolated` is exposed, inspect returned isolation metadata to learn where changes landed. Otherwise arrange explicit worktrees through available execution, or serialize a genuinely shared write. Never switch branches in a shared checkout and call that isolation. -Record returned agent and job identifiers. Results auto-deliver. Read complete output at `agent://` when the runtime exposes it, and otherwise the delivered report; inspect `history://` for incomplete or suspicious reports. Follow up with a write to `agent://`, which revives a parked worker, and broadcast to visible live peers with `agent://all`. `read history://` lists registered agents with status and parent; `read proc://` lists background jobs and project services, and reading one does not consume its delivery. Use only the live operation schema. Reuse a session only when the host reports it can be resumed. Cancel superseded jobs by exact ID; do not infer liveness from transcript timestamps. +Record returned agent and job identifiers. Results auto-deliver. Read complete output at `agent://` when the runtime exposes it, and otherwise the delivered report; inspect `history://` for incomplete or suspicious reports. A nested child's output is at `agent://.`, because a slash after the id is JSON extraction. Follow up with a write to `agent://`, which revives a parked worker, and broadcast to visible live peers with `agent://all`. `read history://` lists registered agents with status and parent; `read proc://` lists background jobs and project services, and reading one does not consume its delivery. `wait` takes no arguments and blocks until the next settled job or peer message, so call it only when nothing else is left to do. Use only the live operation schema. Reuse a session only when the host reports it can be resumed. Cancel a superseded job or owned subagent with `write proc:///kill`; do not infer liveness from transcript timestamps. ## What the live schema gates @@ -94,7 +94,7 @@ Results self-deliver into the director conversation; the vibe tool schema does n When the eval prelude is exposed, it is code inside an eval cell, not a tool schema. Use a fixed `tasks[]` batch for a fixed coverage matrix. For a long open item stream, `workpool()` queues items onto keep-alive workers, `status()` and `peek()` expose progress, and `close()` ends the pool. Final results return through the normal eval result path. Eval's `agent()` returns a handle and accepts `apply` and `merge`; a task item does not. -`browser` and `computer` are eval preludes when exposed. Drive browser tabs and native windows through the live helpers, close browser tabs, and re-observe after navigation or UI changes. Prefer accessibility state over coordinates. Bash remains a separate tool with its named process and `proc://` lifecycle. Probe the live surface instead of pinning an OMP version. +`browser` and `computer` are eval preludes when exposed. Drive browser tabs and native windows through the live helpers, close browser tabs, and re-observe after navigation or UI changes. Prefer accessibility state over coordinates. Bash remains a separate tool with its named process and `proc://` lifecycle. A long-running process a check depends on, such as a dev server, is a `bash` call with a unique `name` and a `ready` condition: a `log` regex, a `port`, or both, in which case both must pass. Read it with `read proc://`, send stdin with `write proc://`, and stop it with `write proc:///kill`. A readiness timeout returns the state and log tail rather than a verdict, so read the log before calling it a fail; the service's exit notice auto-delivers within this session. Probe the live surface instead of pinning an OMP version. ## Lifecycle protocols diff --git a/plugins/pstack/skills/recall/SKILL.md b/plugins/pstack/skills/recall/SKILL.md index 5d31d72..2c01291 100644 --- a/plugins/pstack/skills/recall/SKILL.md +++ b/plugins/pstack/skills/recall/SKILL.md @@ -16,7 +16,7 @@ Transcripts live at the session store, by default `~/.omp/agent/sessions//` buckets. That crosses workspace boundaries and reads private chats from unrelated projects. +Before fanning out, read `skill://omp-mechanics/references/session-store-on-omp.md`. Prefer `history://` for an agent registered in this process. Resolve this conversation's transcript directory using that reference. An explicit session-directory override is already the transcript directory; otherwise select the canonical-cwd bucket under the selected profile root. Search only that directory, never unrelated workspace chats. -```bash -ls -t ~/.omp/agent/sessions//*.jsonl ~/.omp/agent/sessions//*/*.jsonl ~/.omp/agent/sessions//*/*/*.jsonl 2>/dev/null | head -10 +```text +Use glob on /*.jsonl and the selected session's /**/*.jsonl sidecars. Resolve placeholders first, then inspect candidates with read. ``` -That covers the legacy flat (`.jsonl`), current nested (`/.jsonl`), and subagent (`/subagents/.jsonl`) layouts. For each candidate, read the first JSONL line. Take the file whose `message.content[0].text` contains the conversation's opening user prompt. If none matches, pass a tight digest of the session instead. +OMP stores flat `_.jsonl` root sessions and `/.jsonl` child sidecars; nested child directories carry full dotted IDs. A current file starts with a fixed-width `type:title` slot, then a `type:session` header. Legacy files may start at the header. Neither is a user message. Match the opening user prompt against the first `type:message` entry whose `message.role` is `user`, reading its `message.content` text. If no candidate matches, pass a tight digest of this session instead. When a transcript has `reset_boundary` entries, review only the active context after the latest boundary unless the user explicitly asks to review earlier history. ## 2. Spawn three reviewers in parallel -One message, three `Task` calls, `agent: generalPurpose`, agent mode (full tools per spawn). Reviewers need MCP access to look up the tickets, chat threads, and observability traces the transcript references, and nothing strips MCPs here. +Start the three lenses in one `task` call with three items in `tasks[]` and the required shared `context`. Resolve exact discovered judgment, tooling, and divergent agents; omit `agent` for the default worker with the corresponding lens brief when a specialist is absent. Keep independent contexts. Prefer different configured model families, especially for Divergent versus Judgment, only when the roster supplies them; report weaker diversity otherwise. Reviewers may use inherited MCP tools for read-only citation checks. The brief forbids mutations; there is no task `readonly` field. -Resolve exact discovered agent names for the three lenses and the synthesizer. Each agent's frontmatter or `task.agentModelOverrides[]` selects the model; the task item has no `model` field. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. +Resolve exact discovered agent names for the three lenses and the synthesizer. Each agent's frontmatter or `task.agentModelOverrides[]` selects the model; the task item has no `model` field. Use the reasoning and speed already selected by agent frontmatter or exact `task.agentModelOverrides` entries. Do not change operator configuration to satisfy a skill example. | Lens | Exact agent | Prompt template | -|---|---|---|---| +|---|---|---| | Judgment | discovered judgment agent | `references/judgment-reviewer.md` | | Tooling | discovered tooling agent | `references/tooling-reviewer.md` | | Divergent | discovered divergent agent | `references/divergent-reviewer.md` | @@ -36,7 +36,7 @@ Pass each template verbatim, substituting the transcript path or digest where ma ## 3. Synthesize -One `Task` call, `agent: generalPurpose`, `agent` from the `task.agentModelOverrides` entry for an exact discovered agent filling that role (default your strongest judgment model), agent mode (full tools per spawn). It spot-verifies citations through MCP, and nothing strips MCPs here. Pass `references/synthesizer.md` verbatim, with each reviewer's full output inlined where marked. It returns an Accepted / Rejected / Backlog list. +Start the synthesizer in one `task` call with one item in `tasks[]` and the required shared `context`. Use an exact discovered synthesizer, or omit `agent` for the default worker with the synthesizer brief. Agent frontmatter or `task.agentModelOverrides[]` selects the model. Pass `references/synthesizer.md` verbatim, with each reviewer's full output inlined where marked. Permit read-only MCP citation checks and forbid mutations in the brief. It returns an Accepted / Rejected / Backlog list. ## 4. Structural enforcement check diff --git a/plugins/pstack/skills/reflect/references/divergent-reviewer.md b/plugins/pstack/skills/reflect/references/divergent-reviewer.md index 8bc6e1c..740f129 100644 --- a/plugins/pstack/skills/reflect/references/divergent-reviewer.md +++ b/plugins/pstack/skills/reflect/references/divergent-reviewer.md @@ -18,7 +18,7 @@ Scan for: ## Scope to skills and tools the session actually used -Route findings only to skills, tools, or MCPs this transcript used. A skill counts as used when the transcript shows a `Read` of its `SKILL.md` (under `.omp/skills/`, `~/.omp/agent/skills/`, or `~/.omp/plugins/`), a `Task` prompt naming its path, or a tool call (Shell, Grep, MCP, etc.) matching its documented commands. Speculative routings to skills the parent never opened do not count. +Route findings only to skills, tools, or MCPs this transcript used. A skill counts as used when the transcript shows a `read` of its `SKILL.md` (under `.omp/skills/`, `~/.omp/agent/skills/`, or `~/.omp/plugins/`), a `task` item whose brief names its path, or a tool call (`bash`, `grep`, MCP, etc.) matching its documented commands. Speculative routings to skills the parent never opened do not count. Two finding shapes are valid: - The parent invoked the skill and you found a real gap in its body. Route to the skill's relevant section. diff --git a/plugins/pstack/skills/reflect/references/judgment-reviewer.md b/plugins/pstack/skills/reflect/references/judgment-reviewer.md index 619e003..05b3aa0 100644 --- a/plugins/pstack/skills/reflect/references/judgment-reviewer.md +++ b/plugins/pstack/skills/reflect/references/judgment-reviewer.md @@ -17,7 +17,7 @@ Scan for: ## Scope to skills and tools the session actually used -Route findings only to skills, tools, or MCPs this transcript used. A skill counts as used when the transcript shows a `Read` of its `SKILL.md` (under `.omp/skills/`, `~/.omp/agent/skills/`, or `~/.omp/plugins/`), a `Task` prompt naming its path, or a tool call (Shell, Grep, MCP, etc.) matching its documented commands. Speculative routings to skills the parent never opened do not count. +Route findings only to skills, tools, or MCPs this transcript used. A skill counts as used when the transcript shows a `read` of its `SKILL.md` (under `.omp/skills/`, `~/.omp/agent/skills/`, or `~/.omp/plugins/`), a `task` item whose brief names its path, or a tool call (`bash`, `grep`, MCP, etc.) matching its documented commands. Speculative routings to skills the parent never opened do not count. Two finding shapes are valid: - The parent invoked the skill and you found a real gap in its body. Route to the skill's relevant section. diff --git a/plugins/pstack/skills/reflect/references/tooling-reviewer.md b/plugins/pstack/skills/reflect/references/tooling-reviewer.md index 455c1c5..7f80f0d 100644 --- a/plugins/pstack/skills/reflect/references/tooling-reviewer.md +++ b/plugins/pstack/skills/reflect/references/tooling-reviewer.md @@ -28,7 +28,7 @@ Scan for: ## Scope to skills and tools the session actually used -Route findings only to skills, tools, or MCPs this transcript used. A skill counts as used when the transcript shows a `Read` of its `SKILL.md` (under `.omp/skills/`, `~/.omp/agent/skills/`, or `~/.omp/plugins/`), a `Task` prompt naming its path, or a tool call (Shell, Grep, MCP, etc.) matching its documented commands. Speculative routings to skills the parent never opened do not count. +Route findings only to skills, tools, or MCPs this transcript used. A skill counts as used when the transcript shows a `read` of its `SKILL.md` (under `.omp/skills/`, `~/.omp/agent/skills/`, or `~/.omp/plugins/`), a `task` item whose brief names its path, or a tool call (`bash`, `grep`, MCP, etc.) matching its documented commands. Speculative routings to skills the parent never opened do not count. Two finding shapes are valid: - The parent invoked the skill and you found a real gap in its body. Route to the skill's relevant section. diff --git a/plugins/pstack/skills/show-me-your-work/SKILL.md b/plugins/pstack/skills/show-me-your-work/SKILL.md index c3faf85..1e2cbc6 100644 --- a/plugins/pstack/skills/show-me-your-work/SKILL.md +++ b/plugins/pstack/skills/show-me-your-work/SKILL.md @@ -64,7 +64,7 @@ Correct the log, not the story. The audit never edits or removes a row, even an ## Cross-model review of the trail -Before handing back, spawn a subagent on a different model family from the one that did the work. Self-review is not a substitute. The subagent reads the audit trail and the run's transcript, then flags what the user should pay attention to. Not a redo of the work, a scan for what's suboptimal or risky. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. +Before handing back, spawn a subagent on a different model family from the one that did the work. Self-review is not a substitute. The subagent reads the audit trail and the run's transcript, then flags what the user should pay attention to. Not a redo of the work, a scan for what's suboptimal or risky. Use the reasoning and speed already selected by agent frontmatter or exact `task.agentModelOverrides` entries. Do not change operator configuration to satisfy a skill example. - Decisions logged with weak or absent evidence. - Verification steps skipped or claimed without proof in the transcript. diff --git a/plugins/pstack/skills/swarm/SKILL.md b/plugins/pstack/skills/swarm/SKILL.md index fd2b4ea..e90a14f 100644 --- a/plugins/pstack/skills/swarm/SKILL.md +++ b/plugins/pstack/skills/swarm/SKILL.md @@ -19,7 +19,7 @@ Open a todolist with one entry per phase before launching anything. 1. State the done predicate and the artifact or report the swarm must return. 2. Choose the shape. Partition into slices, race N workers on identical briefs, or mix both. For a race or mixed shape, declare `first pass`, `rank all`, or `best-of` before spawning. -4. Pick exact discovered agent names. Inspect each agent's frontmatter or `task.agentModelOverrides[]` for its model. A model race needs one loaded agent file and one exact override entry per arm; overrides cannot create agents. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. +4. Pick exact discovered agent names. Inspect each agent's frontmatter or `task.agentModelOverrides[]` for its model. A model race needs one loaded agent file and one exact override entry per arm; overrides cannot create agents. Use the reasoning and speed already selected by agent frontmatter or exact `task.agentModelOverrides` entries. Do not change operator configuration to satisfy a skill example. 5. Give each worker its own writable output when it writes. When workers verify or measure commits, each brief names the exact SHAs. A measurement brief also names the method (sample count, what one sample is, order). The worker records both in its result. ## Phase B: Fan out diff --git a/plugins/pstack/skills/why/SKILL.md b/plugins/pstack/skills/why/SKILL.md index c8565a1..96a50cd 100644 --- a/plugins/pstack/skills/why/SKILL.md +++ b/plugins/pstack/skills/why/SKILL.md @@ -8,9 +8,9 @@ disable-model-invocation: true Investigate the motivation and intent behind code. The companion `how` skill answers what the code does and how it works. `why` answers what forces led to its shape. -Resolve agents against the live roster. Use an exact preferred agent only when it is present; otherwise follow the workflow's default-worker fallback. Each agent's frontmatter or `task.agentModelOverrides[]` selects its model; the task item has no `model` field. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of the **poteto-mode** skill. +Resolve agents against the live roster. Use an exact preferred agent only when it is present; otherwise follow the workflow's default-worker fallback. Each agent's frontmatter or `task.agentModelOverrides[]` selects its model; the task item has no `model` field. Use the reasoning and speed already selected by agent frontmatter or exact `task.agentModelOverrides` entries. Do not change operator configuration to satisfy a skill example. -Every spawn is `generalPurpose` with full tools per spawn (agent mode). **Do not use readonly/Ask mode.** It strips MCP access, which disables the MCP-backed investigators and the synthesizer's citation spot-checks. Investigators still shouldn't write anything. +Use exact discovered investigators and a synthesizer from the live roster. Omit `agent` for the default worker when no matching specialist is loaded. MCP tools are inherited, including by `scout`; choose a worker whose exposed tools cover the assigned evidence source. Carry read-only posture in each brief: no file writes, git mutations, commits, pushes, PRs, or external mutations. There is no task `readonly` field. ## Operating Posture @@ -46,7 +46,7 @@ Map each available MCP to one evidence category: Source control is always available through git and `gh`. For the other six, classify using the MCP name, server instructions, tool names, and resource descriptors. If an MCP could fit more than one category, choose the one matching its primary evidence. Record ambiguous cases in the coverage map. -Assign one investigator to each category that has a matching MCP, in one `task` call with all items in `tasks[]` and the required shared `context` so they run concurrently, each owning exactly one tool or MCP. Don't ask one agent to cover multiple MCPs. `agent`: an exact discovered agent filling that role, default your fast code model. +Assign one investigator to each category that has a matching MCP, in one `task` call with all items in `tasks[]` and the required shared `context` so they run concurrently, each owning exactly one tool or MCP. Don't ask one agent to cover multiple MCPs. Use an exact discovered investigator agent; its frontmatter or `task.agentModelOverrides[]` selects the model. Each investigator's prompt is `references/investigator-prompt.md` filled in, plus: - the category playbook `references/sources/.md` for the selected MCP, adapted from the examples in `references/source-playbook.md` @@ -76,7 +76,7 @@ If your scope assessment suggests a single-commit trivial target where the PR de ## Step 4. Synthesize -Start one synthesizer in one `task` call with one item in `tasks[]` and the required shared `context`. `agent`: an exact discovered agent filling that role, default your strongest judgment model. It gets: +Start one synthesizer in one `task` call with one item in `tasks[]` and the required shared `context`. Use an exact discovered synthesizer or omit `agent` for the default worker with the synthesizer brief. Its frontmatter or exact override selects the model. It gets: 1. The investigator findings, including any null results and any categories skipped with justification 2. The code anchor 3. The user's original question From 0c172be8a6f03a8a28de335efaae2d263dd2000b Mon Sep 17 00:00:00 2001 From: Negrescu Alexandru Date: Fri, 23 Oct 2026 00:01:58 +0300 Subject: [PATCH 10/10] fix(port): carry hold-unpushed and no-rm-rf cleanup rules, retire stale patch note --- PORTING.md | 8 +++++--- omp-port/patches/worktree-audit-omp.patch | 2 +- omp-port/rules.sed | 3 +++ .../skills/poteto-mode/playbooks/worktree-cleanup.md | 6 +++--- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/PORTING.md b/PORTING.md index 6664171..7469d0c 100644 --- a/PORTING.md +++ b/PORTING.md @@ -216,9 +216,11 @@ The split is by kind, not by size. line, a patch that stops matching costs a merge. - Port prose goes in the owned skills, which `install_tree` restores from `HEAD` and which therefore cannot conflict with upstream at all. -- A patch is for code: control flow, a data shape, a file upstream does not have. Both remaining - patches touch a script, and one of them replaced two interlocking patches that had to be read - together to apply, because one wrote a transcript lookup the other deleted. +- A patch is for code: control flow, a data shape, a file upstream does not have. The patches + touch scripts only. `worktree-audit-omp.patch` is the port's own change. The three `zzz*` + patches carry open upstream PRs (#746 and #769, the check-plan half of #745, and #733 with an + age-limited marker). When upstream merges one, its patch stops applying; delete the patch rather + than resolving it. Author a patch against **post-rules** text. `rules.sed` runs first, so a hunk quoting the raw upstream line fails whenever an earlier rule rewrote it. `worktree-audit-omp.patch` deletes a diff --git a/omp-port/patches/worktree-audit-omp.patch b/omp-port/patches/worktree-audit-omp.patch index 7f1af76..b6c1829 100644 --- a/omp-port/patches/worktree-audit-omp.patch +++ b/omp-port/patches/worktree-audit-omp.patch @@ -1,4 +1,4 @@ -# worktree-audit.sh, the one upstream script the port changes, and every change to it is code +# worktree-audit.sh, the upstream script the port changes most, and every change to it is code # rather than prose, so a unified diff is the right layer for all of it. # # The changes, all of them code: diff --git a/omp-port/rules.sed b/omp-port/rules.sed index e1d4597..a302f63 100644 --- a/omp-port/rules.sed +++ b/omp-port/rules.sed @@ -580,3 +580,6 @@ s#`Task`#`task`#g s#the Task tool#the `task` tool#g s#the full Task schema#the live `task` schema#g s#every Task spawn#every `task` spawn#g +s#then suggests a bucket\. The transcript scan is slow, so background it\.#then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. A `LAST_CHAT` of `?` means the omp session store could not be read, so the row holds at `verify-recent-chat`. The transcript scan is slow, so background it.# +s#`wip:N` is N tracked uncommitted edits\. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone\. `scratch:N` is untracked throwaway, safe to drop, but name the files\.#`wip:N` counts tracked, untracked, and ignored paths. For each candidate, run `git -C status --porcelain=v1 --untracked-files=all --ignored=matching`; show diffs for tracked edits and list untracked and ignored paths without opening their contents, and get a decision first, since removing a clean worktree is recoverable from its branch but none of those files are. Never treat untracked or ignored files as disposable scratch.# +s#Per path, `git worktree remove --force `\. If the dir survives on ignored build artifacts, `rm -rf` it, then `git worktree prune`\.#Re-run the step 4 status preflight immediately before removal, then per path `git worktree remove `, which can still delete ignored-only files, so it is not a preservation check. If Git refuses, stop and retain the worktree; never `rm -rf` it or `git worktree prune` to bypass the refusal.# diff --git a/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index c59f861..b931090 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -1,10 +1,10 @@ ### Worktree and simulator cleanup -1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at a tool-managed path like `.worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. The transcript scan is slow, so background it. +1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at a tool-managed path like `.worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. A `LAST_CHAT` of `?` means the omp session store could not be read, so the row holds at `verify-recent-chat`. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The live omp sessions are the real artifact (principle-prove-it-works). Get that set from `read history://` plus the session store at `~/.omp/agent/sessions//`, confirm it with the user, and cross-check every candidate. The lever has marked `safe` a worktree a live session still owned, so the live set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the session is still live and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A live session spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when `read history://` never names them. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. -4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. -5. Prune the confirmed set. Per path, `git worktree remove --force `. If the dir survives on ignored build artifacts, `rm -rf` it, then `git worktree prune`. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. +4. Pause on irreversible loss. `wip:N` counts tracked, untracked, and ignored paths. For each candidate, run `git -C status --porcelain=v1 --untracked-files=all --ignored=matching`; show diffs for tracked edits and list untracked and ignored paths without opening their contents, and get a decision first, since removing a clean worktree is recoverable from its branch but none of those files are. Never treat untracked or ignored files as disposable scratch. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. +5. Prune the confirmed set. Re-run the step 4 status preflight immediately before removal, then per path `git worktree remove `, which can still delete ignored-only files, so it is not a preservation check. If Git refuses, stop and retain the worktree; never `rm -rf` it or `git worktree prune` to bypass the refusal. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. 6. Simulators and other reclaimers. Simulators are usually the next-biggest win. `xcrun simctl --set testing delete all` (XCTestDevices clones), `xcrun simctl delete unavailable`, and `xcrun simctl runtime list` then `runtime delete ` for old runtimes. More when needed: Xcode `DerivedData` and `iOS DeviceSupport`, `~/Library/Application Support/Cursor` (`state.vscdb.backup`, and `snapshots/roots/` where a `` named for a folder you opened as a workspace balloons), package caches (pnpm, uv, brew, yarn). Clear only caches the user has not said to keep. This is the one playbook that deletes user state with no code review to catch a slip, so the gates above are the review.