plain-forge 1.0.21 → 1.0.22-next.2d602b1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,7 @@ Pick whichever entry point matches how much upfront design you want:
34
34
 
35
35
  - **`debug-specs`** — when the rendered app misbehaves, this traces the generated code back to the spec that caused it and fixes the spec (never the generated code).
36
36
  - **`run-codeplain`** — experimental supervised render. Launches `codeplain` for you, tails the log, watches generated code appear under `plain_modules/`, and detects pathologies (stuck conformance loops, complexity errors, missing concepts, render failures). On approval it stops the renderer, hands off to the right spec-edit skill, and resumes.
37
+ - **`render-spec`** — [pyro](https://github.com/plainlang/pyro), the open-source ***plain renderer, bundled with plain-forge. Renders a `.plain` module (and the modules it requires or imports) into working, tested code directly inside your agent. See [Render with pyro](#render-with-pyro).
37
38
  - **`implement-unit-testing-script`** / **`implement-conformance-testing-script`** / **`implement-prepare-environment-script`** — generate the per-language testing scripts that the renderer and you both invoke. New languages can be added by these skills without touching any other part of the project.
38
39
 
39
40
  Each skill operates on the same one-question-at-a-time, write-immediately, refine-through-follow-ups loop. Specs land on disk after every answer; later answers fix earlier writes in place. There is no batched interview, no "I'll gather context first," and no hand-authored functional specs — every new spec goes through the authoring skills so the complexity and conflict checks actually run.
@@ -187,6 +188,23 @@ If you'd rather have plain-forge babysit the run from your AI coding agent, invo
187
188
 
188
189
  This is an **experimental** feature — the default and most reliable way to render is still the manual `codeplain <module>.plain` invocation above.
189
190
 
191
+ #### Render with pyro
192
+
193
+ plain-forge ships [pyro](https://github.com/plainlang/pyro), an open-source renderer packaged as the `render-spec` skill that renders inside your AI coding agent. Invoke it with the module to render:
194
+
195
+ ```
196
+ /render-spec <module>.plain
197
+ ```
198
+
199
+ It resolves the modules the target requires or imports, renders them in dependency order, and writes:
200
+
201
+ - implementation code and unit tests to `plain_module/code/`
202
+ - conformance tests to `plain_module/tests/`
203
+ - the target module's final output to `dist/`
204
+ - its working files (render plan, dependencies, scenarios, requirement lists) to `.pyro/`
205
+
206
+ pyro needs **Python 3.8 or newer** on the machine and nothing else. `npx plain-forge@latest update` updates pyro along with the rest of plain-forge.
207
+
190
208
  ### Debugging specs
191
209
 
192
210
  Hit a bug in the rendered app, a failing test, or behavior that doesn't match what you specified?
@@ -210,12 +228,39 @@ forge/ # canonical content, copied verbatim on install
210
228
  bin/
211
229
  cli.mjs # the `plain-forge` CLI — `install` and `update` commands
212
230
 
231
+ vendor/
232
+ pyro/ # git submodule: plainlang/pyro, pinned to the release plain-forge ships
233
+
234
+ scripts/
235
+ bundle-pyro.sh # copies vendor/pyro's `render-spec` skill into forge/skills/ at publish time
236
+ deploy.sh # publishes a release to npm from a workstation
237
+
213
238
  test/
214
239
  cli.test.mjs # tests for the install / update CLI
215
240
 
216
241
  package.json # ships only `bin/cli.mjs` and `forge/` to npm
217
242
  ```
218
243
 
244
+ ### Bundled pyro skill
245
+
246
+ The `render-spec` skill comes from [pyro](https://github.com/plainlang/pyro), which has its own release cycle. It is included as a **git submodule** at [`vendor/pyro`](vendor/pyro), so the exact pyro commit plain-forge ships is visible in the repository. Clone with submodules:
247
+
248
+ ```bash
249
+ git clone --recurse-submodules https://github.com/plainlang/plain-forge.git
250
+ # or, in an existing clone:
251
+ git submodule update --init
252
+ ```
253
+
254
+ Agents only discover skills that sit directly under `skills/`, so the npm publish CI job copies `vendor/pyro/skills/render-spec` into `forge/skills/render-spec` (gitignored) with `scripts/bundle-pyro.sh` just before `npm publish`. Run the script yourself to try the skill locally.
255
+
256
+ To ship a newer pyro, bump the submodule to pyro's `main` (it only ever points at pyro's latest stable release) and commit the new pointer:
257
+
258
+ ```bash
259
+ git submodule update --remote vendor/pyro
260
+ git add vendor/pyro
261
+ git commit -m "Bump pyro to vX.Y.Z"
262
+ ```
263
+
219
264
  On `install`, the CLI reads `forge/skills` and `forge/rules` and writes them into the chosen agent directory (`.claude/`, `.agents/` for Codex/Copilot/universal, `.forge/`, or `.opencode/` — see the per-agent table above for global-scope paths), recording every file it wrote in `<agent-dir>/.plain-forge/manifest.json` so `update` can later refresh and prune precisely. For agents that need it, it also wires the rules into `opencode.json` or ForgeCode's `AGENTS.md` (see [How the rules get applied per agent](#how-the-rules-get-applied-per-agent)).
220
265
 
221
266
  ## Available Skills
@@ -227,6 +272,7 @@ On `install`, the CLI reads `forge/skills` and `forge/rules` and writes them int
227
272
  | `forge-plain` | End-to-end QA interview that produces complete `.plain` spec files for a new project |
228
273
  | `init-plain-project` | Lightweight project initializer — scaffolds `template/base.plain` (base impl + test reqs), a stub top-level module, the testing scripts, and `config.yaml`. No functional specs, no concepts, no dry-run. Pair with `add-feature` to grow the project feature-by-feature. |
229
274
  | `add-feature` | Interview the user about a single feature, then write all the specs for it |
275
+ | `render-spec` | [pyro](https://github.com/plainlang/pyro), the open-source renderer: renders a `.plain` module and the modules it requires or imports into working, tested code, from inside your agent. See [Render with pyro](#render-with-pyro). |
230
276
  | `run-codeplain` | **Experimental.** Launch a `codeplain` render and supervise it end-to-end — tails `codeplain.log`, watches generated code appear, detects pathologies (stuck conformance loops, complexity errors, missing concepts, render failures), and on approval stops the renderer, hands off to the right spec-edit skill, and resumes with `--render-from`. The default render path is still the manual `codeplain <module>.plain` command. |
231
277
 
232
278
  ### Spec Authoring
@@ -61,6 +61,9 @@ A linked resource **must not** be any of the following:
61
61
  ```
62
62
 
63
63
  ## File location and path resolution
64
- - Paths are resolved relative to the directory where `codeplain` is run (the current working directory)
64
+ - Paths are resolved against the directory that contains the `.plain` file passed to `codeplain`, not the directory where `codeplain` is run
65
+ - This includes links inside imported and required modules — they resolve against that same directory, not against the directory the imported module itself lives in
66
+ - A path that does not resolve there is then looked up in the `--template-dir` directory (if given), and last in the built-in `standard_template_library`
67
+ - Because the `.plain` file's own directory is the anchor, a render gives the same result from any working directory
65
68
  - Relative paths may traverse into parent directories — `[resource](../../resources/resource.md)` is valid — as long as they resolve to a real text file on disk
66
- - The conventional location is a `resources/` directory; keep the resource inside the project so the path stays stable from wherever `codeplain` is run
69
+ - The conventional location is a `resources/` directory next to the `.plain` file
@@ -40,7 +40,9 @@ Use standard markdown link syntax inside any spec section:
40
40
 
41
41
  ## Path Rules
42
42
 
43
- - Paths are resolved **relative to the directory where `codeplain` is run** (the current working directory).
43
+ - Paths are resolved **against the directory that contains the `.plain` file passed to `codeplain`**, not the directory where `codeplain` is run.
44
+ - This includes links inside imported and required modules — they resolve against that same directory, not against the directory the imported module itself lives in.
45
+ - A path that does not resolve there is then looked up in the `--template-dir` directory (if given), and last in the built-in `standard_template_library`.
44
46
  - Relative paths may traverse into parent directories — `[resource](../../resources/resource.md)` is valid — as long as they resolve to a real text file on disk.
45
47
  - **No external URLs** — only local file references.
46
48
  - **No folder paths** — the target must be a file, not a directory.
@@ -69,7 +71,7 @@ The **only** exceptions are URLs and paths that are *values the produced softwar
69
71
 
70
72
  ### What a linked resource CAN be
71
73
 
72
- - A single file inside the project, reachable by a path relative to where `codeplain` is run — parent-directory traversal (`../`) is allowed (`resources/` is the conventional home).
74
+ - A single file inside the project, reachable by a path relative to the `.plain` file — parent-directory traversal (`../`) is allowed (`resources/` is the conventional home).
73
75
  - A **text-based** file the renderer can read end-to-end: JSON, YAML, XML, HTML, Markdown, plain text, CSV, TSV, source code in any language (`.py`, `.js`, `.ts`, `.go`, `.java`, `.rb`, `.rs`, `.kt`, `.swift`, `.c`, `.cpp`, `.cs`, …), shell scripts, SQL, JSON Schema, OpenAPI, AsyncAPI, Protobuf `.proto`, GraphQL SDL, `.jolt`, `.env.example`, `.toml`, `.ini`, `.proto`, `Dockerfile`, etc.
74
76
 
75
77
  ## Common Resource Types
@@ -101,6 +103,6 @@ The **only** exceptions are URLs and paths that are *values the produced softwar
101
103
  - [ ] **Target is a text-based file** (no binary extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.tiff`, `.webp`, `.ico`, `.pdf`, `.docx`, `.xlsx`, `.pptx`, `.zip`, `.tar`, `.gz`, `.mp3`, `.mp4`, `.wav`, `.exe`, `.so`, `.dylib`, `.class`, `.wasm`, …)
102
104
  - [ ] **No URLs or folder paths anywhere in the surrounding `.plain` content** (not as link targets, not in body prose), with the sole exception of URLs / paths that are runtime values the generated software itself uses
103
105
  - [ ] Path is relative to the `.plain` file, not absolute
104
- - [ ] Path resolves to a real text file on disk from where `codeplain` is run (`../` traversal is allowed)
106
+ - [ ] Path resolves to a real text file on disk relative to the `.plain` file (`../` traversal is allowed)
105
107
  - [ ] Markdown link syntax is correct: `[display text](relative/path)`
106
108
  - [ ] Resource content is relevant and adds value beyond what the spec text says
@@ -0,0 +1,164 @@
1
+ ---
2
+ name: render-spec
3
+ description: >-
4
+ Renders a .plain specification module into working, tested code. Use when
5
+ asked to render, build, or implement a .plain spec file or module: resolves
6
+ and renders its required/imported modules in dependency order, producing
7
+ implementation code in plain_module/code/, conformance tests in
8
+ plain_module/tests/, and the target module's output in dist/.
9
+ metadata:
10
+ version: "0.4.0"
11
+ ---
12
+
13
+ # Skill instructions
14
+
15
+ When starting each step or substep, report with a message "Starting Step <Step_ID>: <Description>".
16
+
17
+ Do every step yourself. Never hand any step to a subagent, Agent/Task tool or workflow. Module count or spec size are not exceptions.
18
+
19
+ # Step 0: Python interpreter and version check
20
+
21
+ `<skill_folder>` is the absolute path of the directory holding this `SKILL.md`. Resolve it now and reuse it everywhere in this skill where `<skill_folder>` is used:
22
+ * On Claude Code it is `${CLAUDE_SKILL_DIR}`; on other agents derive it from the location this skill was loaded from.
23
+ * Never reach a file inside this skill by a relative path.
24
+
25
+ `<python>` is required Python 3.8 or newer. Resolve it now and reuse it everywhere in this skill where `<python>` is used. Try these candidates one at a time, in this order, and stop at the first one whose output contains a `current:` line - its interpreter is `<python>`. Do not run the remaining candidates once one has succeeded, and do not run them in parallel:
26
+ 1. `python3 "<skill_folder>/scripts/check_version.py" check`
27
+ 2. `py -3 "<skill_folder>/scripts/check_version.py" check`
28
+ 3. `python "<skill_folder>/scripts/check_version.py" check`
29
+
30
+ Report the Python tool you settled on. If none of the three prints a `current:` line, Python is a missing dependency: abort the rendering and report it.
31
+
32
+ From that same command's output report with a message "Running pyro: <current>".
33
+ * If the output shows `status: update-available`, warn with a message: "A newer pyro release <latest> is available (this is <current>)".
34
+ * On any other status, say nothing about updates and continue.
35
+
36
+ # Step 1: Introduction, inputs and outputs
37
+
38
+ Your mission is to implement software code as defined by .plain specifications (specs).
39
+ To understand the specs language, load the complete `<skill_folder>/references/plain-lang.md`.
40
+
41
+ Input arguments are:
42
+ * <TargetModule> which is the filename of to the module spec to be rendered.
43
+
44
+ Inputs artifacts are:
45
+ * specs of <TargetModule> and the modules it requires or imports, including referenced files inside the specs.
46
+
47
+ Generated artifacts are:
48
+ * :RenderPlan: is a table listing all the modules for rendering and current render state. :RenderPlan: lives in `./.pyro/render-plan.md`.
49
+ * :plainImplementationCode: lives under `./plain_module/code`. Every module is rendered into this same folder, each building on top of the previously rendered ones.
50
+ * :ConformanceTests: are living under `./plain_module/tests`.
51
+ * Internal working files (dependencies, per-module scenarios and requirements) live under `./.pyro`.
52
+
53
+ ### Available tools and paths
54
+
55
+ `<python>` is the interpreter resolved in Step 0 - always run them with it, and never through a shell of your own, so the same command line works on macOS, Linux and Windows alike.
56
+
57
+ `scripts/plain_sections.py` extracts one section out of any number of specs and prints it to stdout:
58
+ `<python> "<skill_folder>/scripts/plain_sections.py" [--include-filename] [--output <path>] <section> <spec.plain> [<spec.plain> ...]`
59
+ * `<section>` is one of `defs`, `impl-reqs`, `test-reqs`, `func-specs`, `acc-tests`, `all`.
60
+ * `--output <path>` writes the result to `<path>`.
61
+ * Always gather sections with this script - never transcribe or summarize them by hand.
62
+
63
+ # Step 2: Prepare the :RenderPlan:
64
+
65
+ Find and read the frontmatter of <TargetModule> and all specs that <TargetModule> requires.
66
+
67
+ Ensure there are no loops in the dependency graph of modules. If there are, abandon the work and describe the error (loop).
68
+
69
+ Prepare the optimal order for rendering <TargetModule> and all the required modules based on the dependency graph.
70
+
71
+ Prepare a :RenderPlan: : List all the modules to be rendered in the planned rendering order in the example format from `<skill_folder>/references/render-plan.md` and write it to :RenderPlan: file (overwrite if already exists).
72
+
73
+
74
+ ## Step 3: Verifying the environment
75
+
76
+ If any dependency is missing - DO NOT INSTALL ANYTHING, but do:
77
+ * immediatly abort the rendering
78
+ * report with message listing missing dependencies.
79
+
80
+ ### Step 3.1: Dependencies
81
+
82
+ Report with a message "Step 3.1: Dependencies".
83
+
84
+ Gather all :plainImplementationReqs: and :plainTestReqs: from all specs of the modules' to be rendered:
85
+ * `<python> "<skill_folder>/scripts/plain_sections.py" --include-filename impl-reqs <all specs to be rendered>`
86
+ * `<python> "<skill_folder>/scripts/plain_sections.py" --include-filename test-reqs <all specs to be rendered>`
87
+
88
+ Write a list of all the required dependencies for implementation and for tests into `.pyro/dependencies.md` file. Verify all the all the dependendencies are present and update the list.
89
+
90
+ Report with a message showing the `.pyro/dependencies.md`.
91
+
92
+
93
+ ## Step 4: Rendering
94
+
95
+ Render every module yourself - never delegate any module to a subagent, Agent/Task tool or workflow.
96
+
97
+ Before rendering the first module, create the folders `plain_module/code`, `plain_module/tests` and `.pyro` (skip any that already exist).
98
+
99
+ Load the :RenderPlan: and for every module follow precisely the steps:
100
+
101
+ ### Step 4.1: Specs, reqs, test scenarios
102
+ Report with a message "Step 4.1: Loading specs, writing reqs and test scenarios for: <module> + <required-or-imported-modules>".
103
+
104
+ Run the command with helper script, where <specs> are the module's spec followed by the specs of its imported or required modules:
105
+ `<python> "<skill_folder>/scripts/plain_sections.py" all <specs>`
106
+ The output of this command are all the necessary specs for succesfully rendering this module.
107
+
108
+ Write exhaustive conformance test scenarios for every :plainFunctionality: of the <module>'s spec into `.pyro/<module>/scenarios.md`.
109
+ * Scenarios should exhaustively test every :plainFunctionality: and should include :AcceptanceTests:.
110
+ * Get the <module>'s :AcceptanceTests: with `<python> "<skill_folder>/scripts/plain_sections.py" acc-tests <module spec>` and cover every one of them.
111
+
112
+ Write the lists of requirements using the helper script, where <specs> are module's spec plus the specs of its imported modules:
113
+ * impl. reqs: `<python> "<skill_folder>/scripts/plain_sections.py" impl-reqs <specs> --output .pyro/<module>/impl-reqs.md`
114
+ * test reqs: `<python> "<skill_folder>/scripts/plain_sections.py" test-reqs <specs> --output .pyro/<module>/test-reqs.md`
115
+ Both lists hold the requirements verbatim - never paraphrase, reorder or drop any of them.
116
+
117
+ ### Step 4.2: Implement code and tests
118
+ Report with a message "Step 4.2: Implementation of <module>".
119
+
120
+ All the implementation code must be put in the self-contained `plain_module/code` folder. Nothing outside of `plain_module` and `.pyro` folders can be touched during this step.
121
+
122
+ Implement all :plainFunctionality: of <module> specs while respecting all the requirements written in `.pyro/<module>/impl-reqs.md`.
123
+
124
+ Implement :UnitTests:.
125
+
126
+ Implement :ConformanceTests: covering all test scenarios:
127
+ * Read all test scenarios from `.pyro/<module>/scenarios.md`.
128
+ * Read test requirements in `.pyro/<module>/test-reqs.md`.
129
+ * Implement the conformance tests covering all test scenarios and respecting test requirements into the `plain_module/tests` folder.
130
+
131
+ ### Step 4.3: Tests verification
132
+ Report with a message "#Step 4.3: Tests verification of <module>".
133
+
134
+ If any tests are failing, go back to the implementation step (4.2), debug it and fix implementation code:
135
+ * Run and verify all :ConformanceTests: are passing.
136
+ * Run and verify all :UnitTests: are passing.
137
+
138
+ ### Step 4.4: Reqs verification
139
+ Report with a message "Step 4.4: Reqs verification of <module>".
140
+
141
+ Read the list `.pyro/<module>/impl-reqs.md` and for every item:
142
+ * Review if the implementation respects it.
143
+ * Add checkbox with checked/unchecked status to the item.
144
+
145
+ Read the list `.pyro/<module>/test-reqs.md` and for every item:
146
+ * Review if the conformance tests respect it.
147
+ * Add checkbox with checked/unchecked status to the item.
148
+
149
+ IMPORTANT: if any requirement list item is not passing, go back to the implementation (step 4.2), debug and fix it in the code.
150
+
151
+
152
+ When all 4.x steps are done, continue with rendering the next module until no more modules are left.
153
+
154
+ ## Step 5: Finalize and report
155
+
156
+ When all modules are rendered do:
157
+ - remove installed dependency artifacts (e.g. `node_modules/`, `.venv/`, `__pycache__/`, vendored packages) in `plain_module/code`, but keep the dependency manifests and lock files so dependencies can be reinstalled later.
158
+ - copy all of the files in the `plain_module/code` folder to the `./dist` folder:
159
+ `<python> "<skill_folder>/scripts/copy_folder.py" plain_module/code dist`
160
+ - prepare a short report on the :plainImplementationCode: and :ConformanceTests:
161
+ - present commands to run tests (unit and/or conformance tests)
162
+ - present the command to run the rendered <TargetModule>
163
+
164
+ Finally, report with a message: "pyro <current> (latest: <latest>)" - reiterating pyro versions and update warning resolved in Step 0.
@@ -0,0 +1,71 @@
1
+ # .plain Language Reference
2
+
3
+ .plain is a specification language designed for writing software requirements in a clear, structured format. It is used to generate production-ready code from `.plain` spec files using AI. The `.plain` files are the source of truth. They describe what the software should do, how it should be built, and how it should be tested. The generated code is a read-only artifact produced by the renderer.
4
+
5
+ ## .plain File Structure
6
+
7
+ A `.plain` file has a YAML frontmatter section followed by standardized sections (marked with `***section name***` headers):
8
+ - `***definitions***` — declares concepts used throughout the specification
9
+ - `***implementation reqs***` — non-functional requirements about how the software should be built
10
+ - `***test reqs***` — requirements for conformance testing
11
+ - `***functional specs***` — describes what the software should do
12
+
13
+ ## Concept Notation
14
+
15
+ Concepts are the building blocks of .plain specifications. They are written between colons: `:ConceptName:`. Valid characters include letters, digits, plus, minus, dot, and underscore.
16
+
17
+ Concepts must be defined in `***definitions***` before being referenced in other sections. Concept names must be globally unique across the specification and its imports. Concept references must not form cycles — if concept A references concept B, then concept B must not reference concept A.
18
+
19
+ ### Predefined Concepts
20
+
21
+ .plain provides predefined concepts available in all specifications without needing to be defined:
22
+
23
+ | Concept | Meaning |
24
+ |---------|---------|
25
+ | `:plainDefinitions:` | Content of the `***definitions***` section |
26
+ | `:plainImplementationReqs:` | Content of the `***implementation reqs***` section |
27
+ | `:plainFunctionality:` | Content of the `***functional specs***` section |
28
+ | `:plainTestReqs:` | Content of the `***test reqs***` section |
29
+ | `:Implementation:` | The system implementing `:plainFunctionality:` |
30
+ | `:plainImplementationCode:` | The generated implementation code |
31
+ | `:UnitTests:` | Auto-generated unit tests for individual functionalities (configured in `***implementation reqs***`) |
32
+ | `:ConformanceTests:` | Auto-generated tests that verify implementation conforms to specs |
33
+ | `:AcceptanceTest:` / `:AcceptanceTests:` | Tests that validate specific aspects of the implementation |
34
+
35
+ ## Definitions Section (`***definitions***`)
36
+
37
+ Declares concepts used throughout the specification. A concept's definition can come from the module's own `***definitions***` section, from an `import`ed module's definitions, or from a `require`d module's `exported_concepts` (but not transitively). Attributes, constraints and clarifications can be nested as sub-bullets (see the example above).
38
+
39
+ ## Implementation Reqs Section (`***implementation reqs***`)
40
+
41
+ A free-form section for any instructions that steer code generation. Common uses include technology choices, architectural constraints, coding standards, and naming conventions, but it can also contain detailed implementation guidance — data formats, error handling strategies, algorithm descriptions, or any other context the renderer needs to produce correct code. These describe HOW to build the software, not WHAT it should do. Unit-testing specs also go here, not in `***test reqs***`.
42
+
43
+ ## Test Reqs Section (`***test reqs***`)
44
+
45
+ Specifies requirements for conformance testing — test frameworks, execution methods, and testing constraints. It does not refer to unit tests, which are specified in the `***implementation reqs***` section.
46
+
47
+ ## Functional Specs Section (`***functional specs***`)
48
+
49
+ Describes what the software should do. Each bullet point is a single piece of functionality that will be implemented.
50
+
51
+ Each functional spec must be limited in complexity. If a spec is too complex, the renderer responds with "Functional spec too complex!" and it must be broken down into smaller specs. Complexity is measured in lines of code - each spec should imply no more than 200 lines of code.
52
+
53
+ Functional specs are in **sequential order** and rendered incrementally, one by one. When a spec is rendered, only **previous** functional specs are in the renderer's context — those earlier in the list, plus any from `requires` modules; later specs are invisible, so a spec can never reference or anticipate what comes after it. This ordering matters for incremental rendering and for detecting conflicts between specs.
54
+
55
+ ## Acceptance Tests (`***acceptance tests***`)
56
+
57
+ Nested under individual functional specs to specify how to verify correct implementation. They extend conformance tests.
58
+
59
+ ## YAML Frontmatter
60
+
61
+ The frontmatter is enclosed between `---` markers and supports:
62
+
63
+ - **`import`** — includes definitions, implementation reqs, and test reqs from templates. Imported modules must not contain functional specs. The default import directory is `template/` — the `template/` prefix is not needed (e.g., `airplain` resolves to `template/airplain.plain`).
64
+ - **`requires`** — specifies dependencies on other root-level modules that must be built first. Unlike `import`, required modules can contain functional specs and represent complete software modules. Requires paths point to root-level modules (e.g., `auth`, `messaging`).
65
+ - **`description`** — optional description of the specification.
66
+ - **`required_concepts`** — concepts that must be defined by any module that imports this spec.
67
+ - **`exported_concepts`** — concepts made available to modules that `require` this one. **Exports are not transitive.** A concept exported from module `A` is visible only to modules that `requires: A` directly. If `B` `requires: A` and `C` `requires: B`, the concepts `A` exports are not visible to `C` — only the concepts `B` itself lists in its own `exported_concepts` (re-declaring and forwarding them in its own `***definitions***` as needed).
68
+
69
+ ## Comments
70
+
71
+ Lines starting with `>` are ignored when rendering:
@@ -0,0 +1,8 @@
1
+ # Render Plan
2
+
3
+ | Order | Module | Spec File | Imports | Requires | Functional Reqs | Status* |
4
+ |-------|--------|-----------|---------|----------|-----|--------|
5
+ | 1 | m_lib | m_lib.plain | base | - | 1 | done |
6
+ | 2 | m_tool | m_tool.plain | base | m_lib | 3 | done |
7
+
8
+ *Valid statuses: todo, in progress, retrying, done, failed
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env python3
2
+ # Report the render-spec skill's own version and whether a newer stable
3
+ # release has been published.
4
+ #
5
+ # Usage: check_version.py current [--skill-md <path>]
6
+ # check_version.py latest [--url <url>]
7
+ # check_version.py check [--skill-md <path>] [--url <url>]
8
+ #
9
+ # current prints the version this copy of the skill ships with, read from
10
+ # the `metadata: version:` field of the SKILL.md next to this script.
11
+ # latest prints the latest published stable version, read from the
12
+ # [project] version in pyproject.toml on the repository's main
13
+ # branch. The release workflow fast-forwards main only when a stable
14
+ # release is published, so main's pyproject version IS the latest
15
+ # stable release - no git and no GitHub API (and its rate limits)
16
+ # needed. Exits 1 if it cannot be determined.
17
+ # check prints both plus a verdict, one `key: value` per line:
18
+ # current: 0.1.0
19
+ # latest: 0.2.0
20
+ # status: update-available
21
+ # status is one of:
22
+ # update-available a newer stable release is published
23
+ # up-to-date this copy is the latest stable release
24
+ # ahead this copy is newer (a dev or rc checkout)
25
+ # unknown the latest version could not be determined
26
+ # Unlike `latest`, network trouble is not an error here: check
27
+ # degrades to `latest: unknown` / `status: unknown` and exits 0,
28
+ # because an update hint must never break a render.
29
+ #
30
+ # --skill-md <path> and --url <url> (also the = spellings) override where the
31
+ # current and latest versions are read from; the tests use them with fixture
32
+ # files and file:// URLs.
33
+ #
34
+ # Versions are `X.Y.Z` with an optional `-rc.N` prerelease, and a release
35
+ # outranks its own release candidates: 0.1.0 < 0.2.0-rc.1 < 0.2.0.
36
+ #
37
+ # Portability: the standard library only, Python 3.8+, no shell, no git. The
38
+ # output is written as bytes to stay UTF-8 with LF endings on every platform.
39
+ #
40
+ # Exit: 0 ok, 1 SKILL.md missing or without a parseable version (or `latest`
41
+ # could not be determined), 2 usage error.
42
+
43
+ import os
44
+ import re
45
+ import sys
46
+
47
+ # Deliberately no f-strings anywhere in this file: it has to stay parseable by Python 2 so
48
+ # that a probe landing on an old `python` reaches this guard and gets a clear message instead
49
+ # of a SyntaxError traceback.
50
+ if sys.version_info < (3, 8):
51
+ sys.stderr.write(
52
+ "check_version.py: error: Python 3.8 or newer is required, found %s\n"
53
+ % ".".join(str(part) for part in sys.version_info[:3])
54
+ )
55
+ sys.exit(1)
56
+
57
+ PROG = os.path.basename(sys.argv[0])
58
+
59
+ LATEST_URL = "https://raw.githubusercontent.com/plainlang/pyro/main/pyproject.toml"
60
+ FETCH_TIMEOUT = 3 # seconds
61
+ SKILL_MD = os.path.normpath(
62
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "SKILL.md")
63
+ )
64
+
65
+ VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$")
66
+
67
+ USAGE = """usage: %(prog)s current [--skill-md <path>]
68
+ %(prog)s latest [--url <url>]
69
+ %(prog)s check [--skill-md <path>] [--url <url>]
70
+
71
+ current print this skill's own version (from SKILL.md metadata)
72
+ latest print the latest published stable version (pyproject.toml on main)
73
+ check print both and a status: update-available | up-to-date | ahead | unknown
74
+ """ % {"prog": PROG}
75
+
76
+
77
+ def emit(text):
78
+ sys.stdout.buffer.write(text.encode("utf-8"))
79
+ sys.stdout.buffer.flush()
80
+
81
+
82
+ def version_key(version):
83
+ """Sort key for a version string, or None if it is not one we understand.
84
+
85
+ A release sorts above its own release candidates: (X, Y, Z, 1, 0) for a
86
+ release, (X, Y, Z, 0, N) for X.Y.Z-rc.N.
87
+ """
88
+ match = VERSION_RE.match(version)
89
+ if not match:
90
+ return None
91
+ major, minor, patch, rc = match.groups()
92
+ if rc is None:
93
+ return (int(major), int(minor), int(patch), 1, 0)
94
+ return (int(major), int(minor), int(patch), 0, int(rc))
95
+
96
+
97
+ def read_current(skill_md):
98
+ """The version from SKILL.md's frontmatter, or None.
99
+
100
+ The stdlib has no YAML parser, so this scans only the frontmatter block
101
+ (between the opening and closing --- lines) for the one indented
102
+ `version:` key, which lives under `metadata:`.
103
+ """
104
+ try:
105
+ with open(skill_md, "rb") as handle:
106
+ text = handle.read().decode("utf-8", "replace")
107
+ except OSError:
108
+ return None
109
+ lines = text.splitlines()
110
+ if not lines or lines[0].strip() != "---":
111
+ return None
112
+ frontmatter = []
113
+ for line in lines[1:]:
114
+ if line.strip() == "---":
115
+ break
116
+ frontmatter.append(line)
117
+ match = re.search(
118
+ r"(?m)^[ \t]+version:[ \t]*[\"']?([^\"'\s]+)", "\n".join(frontmatter)
119
+ )
120
+ return match.group(1) if match else None
121
+
122
+
123
+ def fetch_latest(url):
124
+ """The [project] version from the pyproject.toml at `url`, or None.
125
+
126
+ Any failure - offline, DNS, TLS, HTTP error, timeout, garbage payload,
127
+ a version string that does not parse - just makes the latest version
128
+ unknown; the caller decides how loud to be. A non-None result is always
129
+ a valid version.
130
+ """
131
+ import urllib.request
132
+
133
+ try:
134
+ with urllib.request.urlopen(url, timeout=FETCH_TIMEOUT) as response:
135
+ text = response.read().decode("utf-8", "replace")
136
+ except Exception:
137
+ return None
138
+ project = re.search(r"(?ms)^\[project\][ \t]*\r?$(.*?)(?=^\[|\Z)", text)
139
+ if not project:
140
+ return None
141
+ match = re.search(r"(?m)^version[ \t]*=[ \t]*[\"']([^\"']+)[\"']", project.group(1))
142
+ if not match or version_key(match.group(1)) is None:
143
+ return None
144
+ return match.group(1)
145
+
146
+
147
+ def compare(current, latest):
148
+ if latest is None:
149
+ return "unknown"
150
+ current_key = version_key(current)
151
+ latest_key = version_key(latest)
152
+ if latest_key > current_key:
153
+ return "update-available"
154
+ if latest_key < current_key:
155
+ return "ahead"
156
+ return "up-to-date"
157
+
158
+
159
+ def parse_args(argv):
160
+ command = None
161
+ options = {"--skill-md": SKILL_MD, "--url": LATEST_URL}
162
+ index = 0
163
+ while index < len(argv):
164
+ name, equals, value = argv[index].partition("=")
165
+ if name in options:
166
+ if not equals:
167
+ index += 1
168
+ if index >= len(argv):
169
+ usage_error("%s needs a value" % name)
170
+ value = argv[index]
171
+ options[name] = value
172
+ elif name in ("current", "latest", "check") and not equals:
173
+ if command is not None:
174
+ usage_error("only one command allowed, got %r after %r" % (name, command))
175
+ command = name
176
+ else:
177
+ usage_error("unknown argument %r" % argv[index])
178
+ index += 1
179
+ if command is None:
180
+ usage_error("a command is required")
181
+ return command, options["--skill-md"], options["--url"]
182
+
183
+
184
+ def usage_error(message):
185
+ sys.stderr.write("%s: error: %s\n\n%s" % (PROG, message, USAGE))
186
+ sys.exit(2)
187
+
188
+
189
+ def main(argv):
190
+ command, skill_md, url = parse_args(argv)
191
+
192
+ if command == "latest":
193
+ latest = fetch_latest(url)
194
+ if latest is None:
195
+ sys.stderr.write(
196
+ "%s: error: could not determine the latest published version\n" % PROG
197
+ )
198
+ return 1
199
+ emit(latest + "\n")
200
+ return 0
201
+
202
+ current = read_current(skill_md)
203
+ if current is None or version_key(current) is None:
204
+ sys.stderr.write(
205
+ "%s: error: no parseable metadata version in %s\n" % (PROG, skill_md)
206
+ )
207
+ return 1
208
+
209
+ if command == "current":
210
+ emit(current + "\n")
211
+ return 0
212
+
213
+ # check: never fail on account of the network.
214
+ latest = fetch_latest(url)
215
+ emit(
216
+ "current: %s\nlatest: %s\nstatus: %s\n"
217
+ % (current, latest or "unknown", compare(current, latest))
218
+ )
219
+ return 0
220
+
221
+
222
+ if __name__ == "__main__":
223
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ # Copy the complete contents of one folder into another, recursively.
3
+ #
4
+ # Usage: copy_folder.py <source> <destination>
5
+ #
6
+ # The destination folder is created if it does not exist. If it already
7
+ # exists, the source contents are merged into it and files with the same
8
+ # name are overwritten - the same semantics as `cp -r <source>/* <dest>/`
9
+ # on unix, but working identically on macOS, Linux and Windows.
10
+ #
11
+ # Exits 0 on success, 1 on error (source missing or not a directory).
12
+ #
13
+ # Requires Python 3.8+ (shutil.copytree dirs_exist_ok).
14
+
15
+ import argparse
16
+ import shutil
17
+ import sys
18
+ from pathlib import Path
19
+
20
+
21
+ def main() -> int:
22
+ parser = argparse.ArgumentParser(
23
+ description="Copy the complete contents of a folder to a destination folder."
24
+ )
25
+ parser.add_argument("source", help="folder to copy from")
26
+ parser.add_argument("destination", help="folder to copy into")
27
+ args = parser.parse_args()
28
+
29
+ source = Path(args.source)
30
+ destination = Path(args.destination)
31
+
32
+ if not source.exists():
33
+ print(f"error: source does not exist: {source}", file=sys.stderr)
34
+ return 1
35
+ if not source.is_dir():
36
+ print(f"error: source is not a directory: {source}", file=sys.stderr)
37
+ return 1
38
+
39
+ try:
40
+ shutil.copytree(source, destination, dirs_exist_ok=True)
41
+ except OSError as exc:
42
+ print(f"error: copy failed: {exc}", file=sys.stderr)
43
+ return 1
44
+
45
+ return 0
46
+
47
+
48
+ if __name__ == "__main__":
49
+ sys.exit(main())
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env python3
2
+ # Extract one section from .plain specs and print the bodies to stdout.
3
+ #
4
+ # Usage: plain_sections.py [--include-filename] [--output <path>] <section> <spec.plain> [spec.plain ...]
5
+ #
6
+ # Sections (case-insensitive, `-` and `_` interchangeable):
7
+ # definitions defs :plainDefinitions:
8
+ # implementation-reqs impl-reqs impl :plainImplementationReqs:
9
+ # test-reqs :plainTestReqs:
10
+ # functional-specs func-specs specs :plainFunctionality:
11
+ # acceptance-tests acc-tests :AcceptanceTests:
12
+ # all all-sections everything - every section, in file order
13
+ #
14
+ # The ***section*** marker itself is never printed, except for `all`, which keeps every
15
+ # section's own marker so the sections stay distinguishable. `>` comment lines are always
16
+ # dropped. With --include-filename each body is preceded by a "## <path>" heading.
17
+ #
18
+ # --output <path> (or --output=<path>) writes the result to <path> instead of stdout, creating
19
+ # missing parent folders, so callers never need shell redirection. Windows PowerShell 5.1
20
+ # writes UTF-16LE for `>` and `>>`, which corrupts files that later steps read back, so
21
+ # redirecting here rather than in the calling shell keeps the output UTF-8/LF on every
22
+ # platform. Warnings stay on stderr.
23
+ #
24
+ # Portability: the standard library only, Python 3.8+, no shell and no awk - so the same
25
+ # command line works from sh, PowerShell and cmd alike. Everything is written as bytes to
26
+ # keep the output UTF-8 with LF endings even on a Windows console, whose text streams would
27
+ # otherwise re-encode to cp1252 and translate \n to \r\n.
28
+ #
29
+ # Exit: 0 ok (a missing section only warns on stderr), 1 unreadable file or unwritable
30
+ # --output path, 2 usage error / unknown section.
31
+
32
+ import os
33
+ import re
34
+ import sys
35
+
36
+ # Deliberately no f-strings anywhere in this file: it has to stay parseable by Python 2 so
37
+ # that a probe landing on an old `python` reaches this guard and gets a clear message instead
38
+ # of a SyntaxError traceback.
39
+ if sys.version_info < (3, 8):
40
+ sys.stderr.write(
41
+ "plain_sections.py: error: Python 3.8 or newer is required, found %s\n"
42
+ % ".".join(str(part) for part in sys.version_info[:3])
43
+ )
44
+ sys.exit(1)
45
+
46
+ PROG = os.path.basename(sys.argv[0])
47
+
48
+ USAGE = """usage: %(prog)s [--include-filename] [--output <path>] <section> <spec.plain> [spec.plain ...]
49
+
50
+ sections (case-insensitive, - and _ interchangeable):
51
+ definitions defs :plainDefinitions:
52
+ implementation-reqs impl-reqs impl :plainImplementationReqs:
53
+ test-reqs :plainTestReqs:
54
+ functional-specs func-specs specs :plainFunctionality:
55
+ acceptance-tests acc-tests :AcceptanceTests:
56
+ all all-sections everything
57
+ """
58
+
59
+ # Every alias, normalized, mapped to the canonical section name the parser matches on.
60
+ SECTIONS = {
61
+ "definitions": "definitions",
62
+ "defs": "definitions",
63
+ "plaindefinitions": "definitions",
64
+ "implementation reqs": "implementation reqs",
65
+ "impl reqs": "implementation reqs",
66
+ "impl": "implementation reqs",
67
+ "plainimplementationreqs": "implementation reqs",
68
+ "test reqs": "test reqs",
69
+ "plaintestreqs": "test reqs",
70
+ "functional specs": "functional specs",
71
+ "func specs": "functional specs",
72
+ "specs": "functional specs",
73
+ "plainfunctionality": "functional specs",
74
+ "acceptance tests": "acceptance tests",
75
+ "acc tests": "acceptance tests",
76
+ "acceptancetests": "acceptance tests",
77
+ "all": "all",
78
+ "all sections": "all",
79
+ "everything": "all",
80
+ }
81
+
82
+ COMMENT_RE = re.compile(r"^[ \t]*>")
83
+ BLANK_RE = re.compile(r"^[ \t]*$")
84
+ MARKER_RE = re.compile(r"^[ \t]*\*\*\*(.*)\*\*\*[ \t]*$")
85
+ SPEC_ITEM_RE = re.compile(r"^-[ \t]")
86
+ TRAILING_WS_RE = re.compile(r"[ \t]+$")
87
+
88
+
89
+ def byte_sink(stream):
90
+ """The binary writer under a text stream - or the stream itself when it already is one."""
91
+ return getattr(stream, "buffer", stream)
92
+
93
+
94
+ def write_text(sink, text):
95
+ """Write text as UTF-8 bytes, bypassing any encoding and newline translation on the way."""
96
+ try:
97
+ sink.write(text.encode("utf-8", "surrogateescape"))
98
+ except TypeError: # a text-only stream substituted by a caller
99
+ sink.write(text)
100
+ sink.flush()
101
+
102
+
103
+ def error(message):
104
+ write_text(byte_sink(sys.stderr), "%s: error: %s\n" % (PROG, message))
105
+
106
+
107
+ def warning(message):
108
+ write_text(byte_sink(sys.stderr), "%s: warning: %s\n" % (PROG, message))
109
+
110
+
111
+ def usage():
112
+ write_text(byte_sink(sys.stderr), USAGE % {"prog": PROG})
113
+ sys.exit(2)
114
+
115
+
116
+ def normalize_section(name):
117
+ """Lowercase, - / _ -> space, collapse and trim whitespace, strip the colons of :Concept:."""
118
+ name = " ".join(name.lower().replace("_", " ").replace("-", " ").split())
119
+ if name.startswith(":"):
120
+ name = name[1:]
121
+ if name.endswith(":"):
122
+ name = name[:-1]
123
+ return name
124
+
125
+
126
+ def marker_name(line):
127
+ """Section name of a ***marker*** line, normalized; None if the line is not a marker."""
128
+ match = MARKER_RE.match(line)
129
+ if match is None:
130
+ return None
131
+ name = re.sub(r"[ \t]+", " ", match.group(1).lower()).strip(" ")
132
+ if name == "": # a bare ****** is not a section marker
133
+ return None
134
+ return name
135
+
136
+
137
+ def indent_of(line):
138
+ width = 0
139
+ while width < len(line) and line[width] in " \t":
140
+ width += 1
141
+ return width
142
+
143
+
144
+ def dedent(line, width):
145
+ return line[min(indent_of(line), width):]
146
+
147
+
148
+ def read_lines(path):
149
+ """Records exactly as awk would see them: split on \\n only, one trailing \\r stripped."""
150
+ handle = open(path, "r", encoding="utf-8", errors="surrogateescape", newline="")
151
+ try:
152
+ text = handle.read()
153
+ finally:
154
+ handle.close()
155
+ if text == "":
156
+ return []
157
+ if text.endswith("\n"):
158
+ text = text[:-1]
159
+ return [line[:-1] if line.endswith("\r") else line for line in text.split("\n")]
160
+
161
+
162
+ def extract(path, want, show_path):
163
+ """The body of `want` in `path`, newline-terminated; None when the section is absent."""
164
+ buf = []
165
+ pending = 0 # blank lines held back, so a trailing run never reaches the output
166
+ in_section = False
167
+ in_fs = False
168
+ in_block = False
169
+ block_indent = 0
170
+ block_content = False
171
+ parent = ""
172
+ last_parent = ""
173
+
174
+ def flush_blanks():
175
+ for _ in range(pending):
176
+ buf.append("")
177
+
178
+ for line in read_lines(path):
179
+ marker = marker_name(line)
180
+ col0 = line.startswith("***")
181
+
182
+ if want == "acceptance tests":
183
+ if marker is not None and col0: # a top-level section starts
184
+ in_fs = marker == "functional specs"
185
+ in_block = False
186
+ continue
187
+ if not in_fs:
188
+ continue
189
+
190
+ if marker == "acceptance tests": # nested, indented under one spec
191
+ in_block = True
192
+ block_indent = indent_of(line)
193
+ pending = 0
194
+ block_content = False
195
+ if parent != "" and parent != last_parent:
196
+ if buf:
197
+ buf.append("")
198
+ buf.append(parent)
199
+ last_parent = parent
200
+ continue
201
+ if marker is not None:
202
+ in_block = False
203
+ continue
204
+
205
+ if in_block:
206
+ if BLANK_RE.match(line):
207
+ pending += 1
208
+ continue
209
+ if indent_of(line) >= block_indent:
210
+ if COMMENT_RE.match(line):
211
+ continue
212
+ if block_content:
213
+ flush_blanks()
214
+ pending = 0 # drop blank lines right after the marker
215
+ block_content = True
216
+ buf.append(dedent(line, block_indent))
217
+ continue
218
+ in_block = False # dedented out of the block; reconsider line
219
+
220
+ if SPEC_ITEM_RE.match(line): # the functional spec this block belongs to
221
+ parent = TRAILING_WS_RE.sub("", line)
222
+ continue
223
+
224
+ # Top-level section mode.
225
+ if marker is not None and col0:
226
+ if want == "all":
227
+ if buf:
228
+ buf.append("") # blank line before each new section
229
+ buf.append(line) # keep the ***marker*** verbatim
230
+ in_section = True
231
+ pending = 0
232
+ elif marker == want:
233
+ in_section = True
234
+ pending = 0
235
+ elif in_section:
236
+ in_section = False
237
+ continue
238
+ if not in_section:
239
+ continue
240
+ if COMMENT_RE.match(line):
241
+ continue
242
+ if BLANK_RE.match(line):
243
+ if buf:
244
+ pending += 1
245
+ continue
246
+ flush_blanks()
247
+ pending = 0
248
+ buf.append(line)
249
+
250
+ while buf and BLANK_RE.match(buf[-1]):
251
+ buf.pop()
252
+ if not buf:
253
+ return None
254
+
255
+ head = ["## " + path, ""] if show_path else []
256
+ return "\n".join(head + buf) + "\n"
257
+
258
+
259
+ def parse_args(argv):
260
+ """Options are recognized wherever they appear, including after the positionals."""
261
+ show_path = False
262
+ out = None
263
+ positional = []
264
+
265
+ args = list(argv)
266
+ while args:
267
+ arg = args.pop(0)
268
+ if arg == "--include-filename":
269
+ show_path = True
270
+ elif arg == "--output":
271
+ if not args:
272
+ error("--output requires a path")
273
+ usage()
274
+ out = args.pop(0)
275
+ elif arg.startswith("--output="):
276
+ out = arg[len("--output="):]
277
+ if out == "":
278
+ error("--output requires a path")
279
+ usage()
280
+ elif arg in ("-h", "--help"):
281
+ usage()
282
+ else:
283
+ positional.append(arg)
284
+
285
+ return show_path, out, positional
286
+
287
+
288
+ def main(argv):
289
+ show_path, out, positional = parse_args(argv)
290
+
291
+ if len(positional) < 2:
292
+ usage()
293
+
294
+ section_arg = positional[0]
295
+ want = SECTIONS.get(normalize_section(section_arg))
296
+ if want is None:
297
+ error("unknown section: %s" % section_arg)
298
+ usage()
299
+
300
+ # Open once, here, so every write below lands in the file. Truncating up front makes an
301
+ # unwritable path a clean error before any spec is read.
302
+ if out is None:
303
+ sink = byte_sink(sys.stdout)
304
+ handle = None
305
+ else:
306
+ try:
307
+ parent = os.path.dirname(out)
308
+ if parent and not os.path.isdir(parent):
309
+ os.makedirs(parent)
310
+ handle = open(out, "wb")
311
+ except (IOError, OSError):
312
+ error("cannot write %s" % out)
313
+ return 1
314
+ sink = handle
315
+
316
+ exit_status = 0
317
+ printed = False
318
+ try:
319
+ for path in positional[1:]:
320
+ if os.path.isdir(path) or not os.access(path, os.R_OK):
321
+ error("cannot read %s" % path)
322
+ exit_status = 1
323
+ continue
324
+
325
+ try:
326
+ body = extract(path, want, show_path)
327
+ except (IOError, OSError):
328
+ error("cannot read %s" % path)
329
+ exit_status = 1
330
+ continue
331
+
332
+ if body is None:
333
+ if want == "all":
334
+ warning("no sections in %s" % path)
335
+ else:
336
+ warning("no '%s' in %s" % (want, path))
337
+ continue
338
+
339
+ if printed:
340
+ write_text(sink, "\n")
341
+ write_text(sink, body)
342
+ printed = True
343
+ finally:
344
+ if handle is not None:
345
+ handle.close()
346
+
347
+ return exit_status
348
+
349
+
350
+ if __name__ == "__main__":
351
+ sys.exit(main(sys.argv[1:]))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plain-forge",
3
- "version": "1.0.21",
3
+ "version": "1.0.22-next.2d602b1",
4
4
  "description": "Conversational spec-writing tool for ***plain specification language",
5
5
  "type": "module",
6
6
  "engines": {