penult 0.0.2__tar.gz

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.
@@ -0,0 +1 @@
1
+ *.ipynb linguist-documentation
@@ -0,0 +1,119 @@
1
+ .claude/handoffs/
2
+ .claude/scratch/
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+
10
+ .DS_Store
11
+ # C extensions
12
+ *.so
13
+
14
+ # TLS certificates
15
+ ## Ignore all PEM files anywhere
16
+ *.pem
17
+ ## Also ignore any certs directory
18
+ certs/
19
+
20
+ # Distribution / packaging
21
+ .Python
22
+ build/
23
+ develop-eggs/
24
+ dist/
25
+ downloads/
26
+ eggs/
27
+ .eggs/
28
+ lib/
29
+ lib64/
30
+ parts/
31
+ sdist/
32
+ var/
33
+ wheels/
34
+ *.egg-info/
35
+ .installed.cfg
36
+ *.egg
37
+ MANIFEST
38
+ _build
39
+
40
+ # PyInstaller
41
+ # Usually these files are written by a python script from a template
42
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
43
+ *.manifest
44
+ *.spec
45
+
46
+ # Installer logs
47
+ pip-log.txt
48
+ pip-delete-this-directory.txt
49
+
50
+ # Unit test / coverage reports
51
+ htmlcov/
52
+ .tox/
53
+ .coverage
54
+ .coverage.*
55
+ .cache
56
+ nosetests.xml
57
+ coverage.xml
58
+ *.cover
59
+ .hypothesis/
60
+ .pytest_cache/
61
+
62
+ # Translations
63
+ *.mo
64
+ *.pot
65
+
66
+ # Django stuff:
67
+ *.log
68
+ local_settings.py
69
+ db.sqlite3
70
+
71
+ # Flask stuff:
72
+ instance/
73
+ .webassets-cache
74
+
75
+ # Scrapy stuff:
76
+ .scrapy
77
+
78
+ # Sphinx documentation
79
+ docs/_build/
80
+
81
+ # PyBuilder
82
+ target/
83
+
84
+ # Jupyter Notebook
85
+ .ipynb_checkpoints
86
+
87
+ # pyenv
88
+ .python-version
89
+
90
+ # celery beat schedule file
91
+ celerybeat-schedule
92
+
93
+ # SageMath parsed files
94
+ *.sage.py
95
+
96
+ # Environments
97
+ .env
98
+ .venv
99
+ env/
100
+ venv/
101
+ ENV/
102
+ env.bak/
103
+ venv.bak/
104
+
105
+ # Spyder project settings
106
+ .spyderproject
107
+ .spyproject
108
+
109
+ # Rope project settings
110
+ .ropeproject
111
+
112
+ # mkdocs documentation
113
+ /site
114
+
115
+ # mypy
116
+ .mypy_cache/
117
+
118
+ # PyCharm
119
+ .idea
penult-0.0.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 thorwhalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
penult-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.5
2
+ Name: penult
3
+ Version: 0.0.2
4
+ Summary: From an idea to a publication draft: research, write, style, illustrate, and post the draft for review
5
+ Project-URL: Homepage, https://github.com/thorwhalen/penult
6
+ Project-URL: Repository, https://github.com/thorwhalen/penult
7
+ Project-URL: Documentation, https://thorwhalen.github.io/penult
8
+ Author: Thor Whalen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent-skills,drafting,essay,llm,publishing,research,substack,writing
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: article>=0.1.5
14
+ Requires-Dist: cw<0.2,>=0.1.1
15
+ Requires-Dist: dol
16
+ Provides-Extra: dev
17
+ Requires-Dist: ductus; extra == 'dev'
18
+ Requires-Dist: penlike; extra == 'dev'
19
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
20
+ Requires-Dist: pytest>=7.0; extra == 'dev'
21
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
22
+ Provides-Extra: docs
23
+ Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
24
+ Requires-Dist: sphinx>=6.0; extra == 'docs'
25
+ Provides-Extra: images
26
+ Requires-Dist: aix[image]; extra == 'images'
27
+ Requires-Dist: illustration; extra == 'images'
28
+ Provides-Extra: publish
29
+ Requires-Dist: article[substack]; extra == 'publish'
30
+ Provides-Extra: style
31
+ Requires-Dist: ductus; extra == 'style'
32
+ Requires-Dist: penlike; extra == 'style'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # penult
36
+
37
+ From an idea to a publication draft: the penultimate step, where the essay exists, is illustrated, sounds like its author, and waits for them to say go.
38
+
39
+ ```bash
40
+ pip install penult
41
+ ```
42
+
43
+ The seed is a GitHub issue. An agent, with the `penult` skill, turns it into an essay posted as a comment on that issue for the author to review, and, once the author approves, into an **unpublished** Substack or Medium draft through [article](https://github.com/thorwhalen/article). penult never publishes to a platform; the draft is as public as the issue it is posted on.
44
+
45
+ ```bash
46
+ penult start https://github.com/you/essays/issues/30 # workspace docs/<slug>/ in the repo: seed.md, brief.md, state
47
+ penult status docs/<slug> # stage, files, what comes next
48
+ penult check docs/<slug> # ductus + penlike over essay.md; the report goes under ~/.local/share/penult, not the repo
49
+ penult image-search "a 1995 modem" --folder docs/<slug> # credited stock / public-domain candidates
50
+ penult image-generate "<prompt>" --name hero --folder docs/<slug> # at most 2 automatically; prompts past that are kept for you
51
+ penult render docs/<slug> && penult post docs/<slug> # the essay as an issue comment, images by raw URL
52
+ penult approve docs/<slug> --note "good to go" # the author's words; this gates publishing
53
+ penult publish docs/<slug> # an unpublished Substack draft; you press Publish
54
+ ```
55
+
56
+ ## What penult holds, and what the agent does
57
+
58
+ The thinking is the agent's: the research that checks every claim and finds the critiques, the draft, the passes that make it sound like the author. The `penult` skill lays that out and names the skills that own each step (`do-research`, `penlike` and its author-choice guide, `deslop`, `ductus`, `illustration`).
59
+
60
+ penult holds what should not be improvised twice:
61
+
62
+ - **the workspace**: `docs/<slug>/` next to the issue, with the seed as fetched, the brief (angle, audience, voice, platform, image budget, assumptions), the research notes, `essay.md`, `images.md` with every candidate, prompt and credit, `img/`, and a state file;
63
+ - **the thin-seed gate**: a seed with no thesis or too few words is reported, and the agent asks the author before researching;
64
+ - **the image budget**: paid generation capped per essay (two by default); every prompt is kept so the author can try the rest by hand;
65
+ - **the review loop**: the essay rendered for a GitHub comment (title, subtitle, images turned into raw URLs, captions kept), posted and updated in place, refusing while an image is not pushed;
66
+ - **the approval gate**: `publish` runs only after `approve`, of the exact text that was posted, and only makes a draft; images in the comment are pinned to the pushed commit so the links outlive the branch.
67
+
68
+ ## Your preferences stay private
69
+
70
+ Voices, platform, image budget, where the `article` credentials file is: `penult prefs` shows them, `penult prefs-set <key> <value>` changes one. They live under `~/.local/share/penult` (set `PENULT_DATA_DIR` to move it), never in a project folder. Credentials are never read by penult itself; `publish` hands the path of the credentials file to `article`.
71
+
72
+ | Preference | Default | Meaning |
73
+ |---|---|---|
74
+ | `voice_models` | `["me"]` | the penlike models the style pass writes with; the first is the base voice |
75
+ | `voice_register` | `essay` | the penlike register to measure against |
76
+ | `audience` | non-technical readers, or software engineers, managers and scientists | narrowed per essay in the brief |
77
+ | `platform` | `substack` | where `publish` sends the approved essay (`substack` or `medium`) |
78
+ | `max_generated_images` | `2` | paid images generated automatically per essay |
79
+ | `min_image_prompts` | `5` | prompts the agent writes into `images.md` |
80
+ | `article_env_file` | `~/.config/article/.env` | the `article` credentials file |
81
+ | `article_state_path` | `~/.local/share/article/pipeline_state.json` | the `article` state file |
82
+ | `workspace` | `docs/{slug}` | where a workspace goes in the issue's repository |
83
+ | `research_depth` | `deep` | the `do-research` depth |
84
+
85
+ ## From Python
86
+
87
+ Every command is a function in `penult.tools`, taking and returning JSON-ready values. The in-memory store (`files=`), the issue fetcher (`fetch=`), the command runner (`runner=`), the image searcher and generator (`searcher=`, `generator=`) are keyword arguments, which is how the tests run without a network:
88
+
89
+ ```python
90
+ import penult
91
+ started = penult.start("https://github.com/you/essays/issues/30", into="docs/my-essay")
92
+ penult.image_generate("a woodcut of a modem", name="hero", folder="docs/my-essay")
93
+ penult.render("docs/my-essay"); penult.post("docs/my-essay")
94
+ ```
95
+
96
+ ## Install the skill
97
+
98
+ ```bash
99
+ gh skill install thorwhalen/penult penult --agent claude-code # needs a GitHub CLI with `gh skill`
100
+ ```
101
+
102
+ Or copy (or symlink) `penult/data/skills/penult/` into your agent's skills folder; it ships with the package. Extras: `pip install 'penult[publish]'` for the Substack step, `'penult[images]'` for search and generation, `'penult[style]'` for the checks.
103
+
104
+ ## Related
105
+
106
+ - [article](https://github.com/thorwhalen/article): Markdown to an unpublished Substack draft, and syndication with canonical URLs. penult's publishing step.
107
+ - [penlike](https://github.com/thorwhalen/penlike): how an author writes, and the author-choice guide (`penlike guide`).
108
+ - [ductus](https://github.com/thorwhalen/ductus): which passages read as machine-written, and why.
109
+ - [illustration](https://github.com/thorwhalen/illustration): existing images with licence and attribution.
penult-0.0.2/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # penult
2
+
3
+ From an idea to a publication draft: the penultimate step, where the essay exists, is illustrated, sounds like its author, and waits for them to say go.
4
+
5
+ ```bash
6
+ pip install penult
7
+ ```
8
+
9
+ The seed is a GitHub issue. An agent, with the `penult` skill, turns it into an essay posted as a comment on that issue for the author to review, and, once the author approves, into an **unpublished** Substack or Medium draft through [article](https://github.com/thorwhalen/article). penult never publishes to a platform; the draft is as public as the issue it is posted on.
10
+
11
+ ```bash
12
+ penult start https://github.com/you/essays/issues/30 # workspace docs/<slug>/ in the repo: seed.md, brief.md, state
13
+ penult status docs/<slug> # stage, files, what comes next
14
+ penult check docs/<slug> # ductus + penlike over essay.md; the report goes under ~/.local/share/penult, not the repo
15
+ penult image-search "a 1995 modem" --folder docs/<slug> # credited stock / public-domain candidates
16
+ penult image-generate "<prompt>" --name hero --folder docs/<slug> # at most 2 automatically; prompts past that are kept for you
17
+ penult render docs/<slug> && penult post docs/<slug> # the essay as an issue comment, images by raw URL
18
+ penult approve docs/<slug> --note "good to go" # the author's words; this gates publishing
19
+ penult publish docs/<slug> # an unpublished Substack draft; you press Publish
20
+ ```
21
+
22
+ ## What penult holds, and what the agent does
23
+
24
+ The thinking is the agent's: the research that checks every claim and finds the critiques, the draft, the passes that make it sound like the author. The `penult` skill lays that out and names the skills that own each step (`do-research`, `penlike` and its author-choice guide, `deslop`, `ductus`, `illustration`).
25
+
26
+ penult holds what should not be improvised twice:
27
+
28
+ - **the workspace**: `docs/<slug>/` next to the issue, with the seed as fetched, the brief (angle, audience, voice, platform, image budget, assumptions), the research notes, `essay.md`, `images.md` with every candidate, prompt and credit, `img/`, and a state file;
29
+ - **the thin-seed gate**: a seed with no thesis or too few words is reported, and the agent asks the author before researching;
30
+ - **the image budget**: paid generation capped per essay (two by default); every prompt is kept so the author can try the rest by hand;
31
+ - **the review loop**: the essay rendered for a GitHub comment (title, subtitle, images turned into raw URLs, captions kept), posted and updated in place, refusing while an image is not pushed;
32
+ - **the approval gate**: `publish` runs only after `approve`, of the exact text that was posted, and only makes a draft; images in the comment are pinned to the pushed commit so the links outlive the branch.
33
+
34
+ ## Your preferences stay private
35
+
36
+ Voices, platform, image budget, where the `article` credentials file is: `penult prefs` shows them, `penult prefs-set <key> <value>` changes one. They live under `~/.local/share/penult` (set `PENULT_DATA_DIR` to move it), never in a project folder. Credentials are never read by penult itself; `publish` hands the path of the credentials file to `article`.
37
+
38
+ | Preference | Default | Meaning |
39
+ |---|---|---|
40
+ | `voice_models` | `["me"]` | the penlike models the style pass writes with; the first is the base voice |
41
+ | `voice_register` | `essay` | the penlike register to measure against |
42
+ | `audience` | non-technical readers, or software engineers, managers and scientists | narrowed per essay in the brief |
43
+ | `platform` | `substack` | where `publish` sends the approved essay (`substack` or `medium`) |
44
+ | `max_generated_images` | `2` | paid images generated automatically per essay |
45
+ | `min_image_prompts` | `5` | prompts the agent writes into `images.md` |
46
+ | `article_env_file` | `~/.config/article/.env` | the `article` credentials file |
47
+ | `article_state_path` | `~/.local/share/article/pipeline_state.json` | the `article` state file |
48
+ | `workspace` | `docs/{slug}` | where a workspace goes in the issue's repository |
49
+ | `research_depth` | `deep` | the `do-research` depth |
50
+
51
+ ## From Python
52
+
53
+ Every command is a function in `penult.tools`, taking and returning JSON-ready values. The in-memory store (`files=`), the issue fetcher (`fetch=`), the command runner (`runner=`), the image searcher and generator (`searcher=`, `generator=`) are keyword arguments, which is how the tests run without a network:
54
+
55
+ ```python
56
+ import penult
57
+ started = penult.start("https://github.com/you/essays/issues/30", into="docs/my-essay")
58
+ penult.image_generate("a woodcut of a modem", name="hero", folder="docs/my-essay")
59
+ penult.render("docs/my-essay"); penult.post("docs/my-essay")
60
+ ```
61
+
62
+ ## Install the skill
63
+
64
+ ```bash
65
+ gh skill install thorwhalen/penult penult --agent claude-code # needs a GitHub CLI with `gh skill`
66
+ ```
67
+
68
+ Or copy (or symlink) `penult/data/skills/penult/` into your agent's skills folder; it ships with the package. Extras: `pip install 'penult[publish]'` for the Substack step, `'penult[images]'` for search and generation, `'penult[style]'` for the checks.
69
+
70
+ ## Related
71
+
72
+ - [article](https://github.com/thorwhalen/article): Markdown to an unpublished Substack draft, and syndication with canonical URLs. penult's publishing step.
73
+ - [penlike](https://github.com/thorwhalen/penlike): how an author writes, and the author-choice guide (`penlike guide`).
74
+ - [ductus](https://github.com/thorwhalen/ductus): which passages read as machine-written, and why.
75
+ - [illustration](https://github.com/thorwhalen/illustration): existing images with licence and attribution.
@@ -0,0 +1,48 @@
1
+ # Design
2
+
3
+ *2026-11-06. Decided before the first commit (the `architecture-first` procedure). What sits inside each boundary is the simplest thing that works; later iterations add at these boundaries.*
4
+
5
+ ## The decision: a new package, not an extension of `article`
6
+
7
+ `article` turns a Markdown file into an unpublished Substack draft and syndicates with canonical URLs. It is a publishing adapter and should stay one: its PyPI release must not depend on an LLM, an agent host, an image API or a style model. The path from an idea to that Markdown file is a different concern (a workbench for an agent: workspace, state, budgets, review loop) that *depends on* `article`, `penlike`, `ductus`, `illustration` and `aix`. So: `penult` (the penultimate step), with `article` as its publishing seam.
8
+
9
+ ## v1 is done when
10
+
11
+ ```
12
+ penult start <issue url> && penult status <ws> # workspace with seed and brief, thin-seed verdict
13
+ # ... the agent researches, drafts, styles, adds images ...
14
+ penult render <ws> && penult post <ws> # the draft is a comment on the issue, images inline
15
+ penult approve <ws> && penult publish <ws> # an unpublished Substack draft, editor link printed
16
+ ```
17
+
18
+ `tests/test_smoke.py::test_the_one_command_path` is that command with fakes for every external call. It must keep passing after any seam swap.
19
+
20
+ ## Seam table
21
+
22
+ | # | Seam | v1 default (no new dependency) | Replacement you can point at |
23
+ |---|---|---|---|
24
+ | 1 | where private preferences live | `dol` files under `~/.local/share/penult`, `files=` | any `MutableMapping` (tests pass a dict); the same seam as penlike's store |
25
+ | 2 | the seed, and how it is read and commented on | a GitHub issue through the `gh` command: `fetch=` returns the seed dict (`kind`, owner, repo, number, url, title, body, comments), `runner=` runs the commands | another transport is a `runner`; another seed *kind* (a Markdown file, a Discussion) is a new `fetch` and a `kind` branch inside `start` and `post`, not a caller change: the CLI argument is already `seed` |
26
+ | 3 | image search | `illustration.search`, `searcher=` | any callable returning hits with url / licence / attribution |
27
+ | 4 | image generation | `aix.image.generate_image`, `generator=` | `falaw` (used for the Agile, Reformed woodcuts), any `(prompt, path=) -> path` |
28
+ | 5 | publishing | `article_publisher`: the `article` command with `--json-out` (Substack draft; Medium import step), `publisher=` | any `(platform, essay, prefs=, dry_run=, update=) -> {draft_url}`; article's other adapters when they go live |
29
+
30
+ Surface for v1: the shipped `penult` skill and the CLI (`cw.dispatch` over `TOOLS`). MCP would need no core change (verbs are JSON in/out); HTTP would need per-request state (the workspace is already a path argument, so none is global). Not built.
31
+
32
+ NOT seams, written directly: the thin-seed rule (word count and a sentence), the workspace layout, the GitHub-comment template, the check report format, the stage names.
33
+
34
+ ## Concerns kept apart
35
+
36
+ - **Preferences** (user-facing seam): one JSON file with defaults in code; `prefs` / `prefs-set`. The author-choice guide is *not* here: which penlike model fits which situation is penlike's concern (`penlike guide`), and penult only names the base voice.
37
+ - **The thinking** (research, drafting, voice passes) is the agent's and lives in the skill, which points at the skills that own each step. The package holds no prompts.
38
+ - **Credentials** are never read by penult; `publish` passes the path of the `article` credentials file.
39
+
40
+ ## The next changes already known
41
+
42
+ - A second seed kind (a Markdown file, a Discussion) is a new `fetch` behind seam 2 plus a `kind` branch inside `start` (where the workspace goes) and `post` (where the review happens); the CLI argument is already `seed`, so callers do not change.
43
+ - Image display width on Substack is `article`'s open issue (#11); nothing in penult changes for it.
44
+ - A Medium path that does not wait for the Substack post to be live would be a flag on `publish`, inside seam 5.
45
+
46
+ ## Review
47
+
48
+ The adversarial review of v1 is `review-v1.md`; its blockers and majors were fixed before the merge (private check output moved under the data root, image origins kept only when URLs, Windows path form, ductus span quotes, subtitle spacing, approval and render bound to the essay's hash, `--force` keeps the brief, a `publish` extra, `python -m` instead of `which`, raw URLs pinned to the commit, `-z` git parsing, `--json-out` and per-platform draft links, a User-Agent for downloads, typed preferences, a `publisher=` seam, honest wording about the public comment). The minors it lists that are not fixed are the backlog.
@@ -0,0 +1,37 @@
1
+ # Research: what existed before penult
2
+
3
+ *2026-11-06 · scope: the fleet's own packages, skills and reports for the idea-to-draft path; a free name · requested for: whether to extend `article` or build a new package.*
4
+
5
+ ## Summary and recommendation
6
+
7
+ Everything the path needs exists as separate components; nothing orchestrates them. `article` publishes Markdown to Substack and syndicates [1]; `penlike` models voices [2]; `ductus` finds machine-sounding passages [3]; `illustration` finds credited images [4]; `aix` generates images [5]; the `do-research`, `deslop` and `acquaint-write` skills own research and prose. The precedent essay (Agile, Reformed, essays#31) was produced by hand-composing these over three weeks, and the briefing of that process is the specification for the orchestration [6]. Recommendation: a new small package, `penult`, that depends on these and holds only state, budgets and hand-offs; `article` stays a publishing adapter.
8
+
9
+ ## What already exists (in-house)
10
+
11
+ | Component | What it covers | Verdict |
12
+ |---|---|---|
13
+ | `article` [1] | Markdown (footnotes, captions, LaTeX, local images) to an unpublished Substack draft; `--update`, `--dry-run`, tables as code or image; Medium as an Import-a-story step; canonical URLs | depend: the publishing seam |
14
+ | `penlike` [2] | per-author, per-register style models; `brief`, `check`, `notes`; now `guide` / `guide-add`, the author-choice guide added for this build (penlike#9) | depend: voice |
15
+ | `ductus` [3] | `gauge` with per-segment lean and strength, multi-reader consensus, `baseline` on the author's own texts | depend: machine-sounding passages |
16
+ | `illustration` [4] | one `search()` over Openverse and other sources, licence and attribution on every hit | depend: existing images |
17
+ | `aix.image.generate_image` [5], `oa.dalle` | paid generation through litellm / OpenAI | depend, behind a budget |
18
+ | `falaw` | the woodcut-style generations of the precedent essay | pointer for the generator seam |
19
+ | skills `do-research`, `deslop`, `acquaint-write`, `add-references`, `research-topic` | research procedure and prose quality | the skill cites them |
20
+ | report: publishing-substack-vs-medium [7] | Substack as primary, Medium by import, the unofficial python-substack route | settled; informs the platform default |
21
+ | briefing: the Agile, Reformed process [6] | the fourteen steps actually followed, the user's corrections | the specification |
22
+
23
+ Not found, after `ir discover packages` and `ir discover skills` in two phrasings each and `projreg search`: any package or skill that takes a seed through research, drafting, voice, images and a review loop. That gap is this package.
24
+
25
+ ## The name
26
+
27
+ Checked 2026-11-06 with the `short-name-free` script against PyPI and npm: `penult` free on both (also free: `inklings`, `galleyproof`, `penultima`, `firstdraft`, `fromseed`; taken: `galley`, `inkling`, `drafter`, `scrawl`, `essayist`, `scrivener`, `vellum`, `nib`). Chosen for its meaning (the penultimate step, before publication) and its echo of `penlike`.
28
+
29
+ ## REFERENCES
30
+
31
+ [1] [article: tools to semi-automate the publication of articles](https://github.com/thorwhalen/article)
32
+ [2] [penlike: model how an author writes](https://github.com/thorwhalen/penlike)
33
+ [3] [ductus: gauge which parts of a text read as machine-written](https://github.com/thorwhalen/ductus)
34
+ [4] [illustration: find existing images to illustrate text](https://github.com/thorwhalen/illustration)
35
+ [5] [aix: AI utilities, including image generation](https://github.com/thorwhalen/aix)
36
+ [6] [Briefing: the Agile, Reformed publishing process](https://github.com/thorwhalen/essays/blob/master/docs/briefings/2026-11-06-agile-reformed-publishing-process.md)
37
+ [7] [Where to publish the essay: Substack vs Medium (2026)](https://github.com/thorwhalen/essays/blob/master/docs/The%20Agile%20Religion%20and%20the%20Agentic%20Reformation/publishing-substack-vs-medium.md)
@@ -0,0 +1,61 @@
1
+ # Adversarial review of penult v1
2
+
3
+ *2026-11-06, branch `v1` at 4802e40. Read-only review: design.md, .claude/CLAUDE.md, README, every module, the skill, the tests, pyproject. `pytest -q`: 6 passed. `ruff check .`: clean, but the config selects only D100; `ruff --select F,B,UP,SIM` finds 6 (F541 tools.py:251, UP031 tools.py:161, ...). `python -m penult --help`: 13 verbs. Findings were reproduced against real git, real ductus/penlike output, and article's CLI where marked "verified".*
4
+
5
+ Format: severity, file:line, the problem, then the fix in one line.
6
+
7
+ ## Blockers
8
+
9
+ - **blocker** penult/tools.py:383 + penult/images.py:155 + penult/data/skills/penult/SKILL.md:58: the skill says "commit and push the workspace" (thorwhalen/essays is PUBLIC), and two files in it hold private data. `check.md` carries penlike's figures from the author's private model ("the author's usual 0.38 (spread 0.33, over 8 texts)"), and a penlike failure there lists every private model name (verified). `penult.json` `images[].origin` stores the absolute local path of every `image-add` source, e.g. `/Users/<name>/Downloads/x.png` (verified). Fix: write check.md and the penlike half of `last_check` under the data root (or a gitignored `.penult/` that `start` gitignores), and store `origin` only when it is a URL.
10
+ - **blocker** penult/github.py:152: `names` are `str(Path.relative_to(...))`, so they have backslashes on Windows, while git prints `/`. Nothing ever matches, so the push check always passes on Windows. `test_the_one_command_path` expects a refusal there and will fail on the Windows CI job (`test_on_windows = true`), which blocks the release. Fix: use `.as_posix()` (and `-z` parsing, see below).
11
+
12
+ ## Major
13
+
14
+ - **major** penult/tools.py:243: ductus segments have no `text` key; the text is at `span.quote` (verified against `ductus gauge --format json`, schema keys `span/signals/lean/strength/label`). Every flagged line in check.md reads `""` (verified). `direction == "machine"` and `note` are right. Fix: `(seg.get("span") or {}).get("quote")`.
15
+ - **major** penult/render.py:70-73: `"\n".join(["# T", "", "*S*", ""]) + body` leaves a single newline after the subtitle, so `*S*` fuses into the first paragraph of every posted draft (verified: `'# T\n\n*S*\nFirst para.'`). The module doctest only passes because `NORMALIZE_WHITESPACE` hides the missing blank line. Fix: `"\n".join(head) + "\n" + body.strip()`, and assert `"*S*\n\nFirst"` in the smoke test.
16
+ - **major** penult/tools.py:401-412: the approval is never invalidated. After `approve`, an edited essay.md, a re-`post`, or `start --force` (stage back to `seeded`) all leave `approved` set, so `publish` sends text the author never saw (verified). Fix: store the sha256 of essay.md and the comment version in `approved`; `publish` refuses on a mismatch, and `post`/`start --force` clear it.
17
+ - **major** penult/tools.py:115: `start --force` ("refetch the seed") also overwrites brief.md, which destroys the filled-in thesis, assumptions, and the author's answers to the thin-seed questions. It also resets `stage` to `seeded` (tools.py:122). Fix: never rewrite brief.md once it exists (offer `--rewrite-brief`), and keep the stage.
18
+ - **major** pyproject.toml:23-27: `publish` needs `article[substack]` (python-substack, markdown-it), but the core depends on bare `article` and no penult extra pulls it in. After `pip install penult`, `penult publish` fails inside article. Fix: add the extra `publish = ["article[substack]"]` (or depend on `article[substack]`) and name it in the error at tools.py:438.
19
+ - **major** penult/tools.py:188, 437: ductus, penlike and article are found with `shutil.which`. Under `pipx`/`uv tool install 'penult[style]'` or an unactivated venv, their scripts are not on PATH, so check reports "not installed" and publish refuses even though the packages are installed. Fix: run `[sys.executable, "-m", "<pkg>"]` when `importlib.util.find_spec(pkg)` finds them, and fall back to PATH.
20
+ - **major** penult/github.py:142: raw URLs pin the *current branch*. In the owner's own flow (branch, squash-merge, delete the branch) every image in the posted review comment 404s after landing. On a private essays repo raw URLs never render in comments at all. Fix: pin the commit SHA (`git rev-parse HEAD`) in the raw URL; when the repo is private, warn or use `github.com/<o>/<r>/blob/<sha>/<path>?raw=true`.
21
+ - **major** penult/github.py:155-163: porcelain/log output is parsed by `line[3:].strip('"')`. Non-ASCII names come back octal-escaped (`"docs/ess/img/caf\303\251.png"`) and staged renames as `old -> new`, so those images count as pushed and `post` goes ahead with broken pictures (verified with café.png, both untracked and committed-but-unpushed). Fix: `git status --porcelain=v1 -z` and `git log -z --name-only`, then split on NUL.
22
+ - **major** penult/tools.py:383: `[p for p in images if p.is_file()]` drops images that do not exist from the push check, so `post` posts a comment with 404 images that `render` reported as missing. `post` also never checks that comment.md is newer than essay.md, so a stale render gets posted. Fix: refuse when any referenced image is missing, and record the essay hash at render and compare it in `post`.
23
+ - **major** penult/tools.py:455-457: the draft link is scraped from human text (`Edit the draft:` plus `\S+`), which picks up a trailing `.` or `);` (verified), and a Medium run sets `draft_url=None`. That erases the Substack draft link, so the next Substack publish runs without `--update` and makes a duplicate draft. Fix: call `article draft-substack --json-out`, read `results[0].detail.edit_url`, and keep `drafts[platform]` in state.
24
+ - **major** penult/images.py:143: `urllib.request.urlopen` sends the default `Python-urllib` user agent, which Wikimedia rejects with 403 (verified on upload.wikimedia.org). Many Openverse hits are Wikimedia, so `image-search` followed by `image-add <url>` fails on the main path. Fix: send a descriptive User-Agent (or use illustration's own downloader).
25
+ - **major** penult/store.py:151-158: `prefs-set` does not check types. `penult prefs-set voice_models me` stores the string `"me"` (verified), and then `check` runs penlike for models `m` and `e` while the brief says `m, e`. `max_generated_images two` raises a raw `ValueError` in `image-generate`. Fix: coerce or validate against `type(DEFAULT_PREFERENCES[key])` (a str goes to `[str]` for list keys) and raise PenultError otherwise.
26
+ - **major** misc/docs/design.md:25,42 + penult/tools.py:78-125: seam 2 is partly fake. The replacement it names (hubcap, REST) cannot plug into `runner=`, which is subprocess-argv shaped, without faking `gh` argv; `post_comment`/`edit_comment` are called directly. The "second seed type" is not additive either. `start` takes `issue_url` and requires `owner`/`repo`/`number`/`url` (tools.py:102,121), `post` needs an issue URL (`parse_issue_url`), and a Discussion needs GraphQL `addDiscussionComment`. "start already takes the fetched dict" is false. Fix before the first release: a declared `Seed` TypedDict (with `review_target` optional), a `reviewer=` seam (post/edit callable) next to `fetch=`, and the positional renamed to `seed` now, while renaming is free.
27
+ - **major** penult/tools.py:439-450: seam 5 has no injection point. Platforms are an `if/elif` on hardcoded argv, so the replacement the table points at (article's dev.to and hashnode adapters) means editing `publish`. Fix: a `{platform: argv_builder}` registry or a `publisher=` callable.
28
+ - **major** penult/data/skills/penult/SKILL.md:3,11,85 + README.md:9: "Never publishes publicly / nothing you do publishes anything", yet the whole essay is posted as a comment on a public issue before its Substack publication: indexed, and a duplicate-content source for the later canonical URL. Fix: say so plainly in the skill and README, and add a `review_repo` preference (a private repo) or warn in `post` when the repo is public.
29
+
30
+ ## Minor
31
+
32
+ - **minor** penult/images.py:53: illustration's `ImageResult` names the landing page `source_page_url`, which is not among the names tried, so `page` is always None and CC-BY credits lose their source link. Fix: add `source_page_url` (and `author_url`, `license_url`).
33
+ - **minor** penult/render.py:30: the image regex misses single-quoted and parenthesised titles, `<angle bracket paths>`, paths with spaces, escaped quotes in captions, nested brackets in alt text, and reference-style `![a][ref]` images. These are neither rewritten nor checked by `post`, so they become broken relative links in the comment (verified). Fix: parse with markdown-it (already in article[substack]) instead of a regex.
34
+ - **minor** penult/render.py:65: `src.lstrip("./")` strips characters, not a prefix: `../shared/x.png` becomes `shared/x.png` and `/docs/x.png` becomes `docs/x.png`. `quote()` turns `%20` into `%2520`. Fix: `posixpath.normpath(posixpath.join(rel_folder, unquote(src)))` against the repo root.
35
+ - **minor** penult/render.py:67-69: the caption is inserted inside a linked image (`[![a](u)\n\n*Cap*](link)` breaks the link), images inside code fences are rewritten, and a `*` in a caption breaks the italics (verified). Fix: skip fenced code, keep a linked image's caption outside the link, and escape `*`/`_`.
36
+ - **minor** penult/render.py:51-54: `ArticleValidationError` (an essay with no title) is not a PenultError, so the CLI prints a traceback (verified). Fix: catch `article.base.ArticleError` in `_fields` and re-raise it as PenultError.
37
+ - **minor** penult/tools.py:348,356: `folder_url` is computed and then `del`eted: dead code. Fix: delete both lines.
38
+ - **minor** penult/github.py:35: `_REMOTE` rejects repos with dots (`o/my.repo.git`, `*.github.io`) and ssh host aliases (`github-work:`), and tools.py:102 compares owner/repo case-sensitively, so `start` refuses from a valid checkout. Fix: `([^/]+?)(?:\.git)?/?$` and `.lower()` on both sides.
39
+ - **minor** penult/github.py:34: `_ISSUE_URL` is unanchored (it accepts `notgithub.com/...`). Fix: `^https?://(www\.)?github\.com/`.
40
+ - **minor** penult/data/skills/penult/SKILL.md:30-54: `penult mark --stage drafted`, `image-search`, `image-add` and `image-generate` are shown without a workspace, so they fail unless the agent's cwd is the workspace (start is run from the repo root). Fix: write `<workspace>` / `--folder <workspace>` in every example.
41
+ - **minor** penult/__main__.py:58 (via cw): short flags collide: `-f` is `--force` (start) and `--folder` (image-search, image-add); `-n` is `--n`, `--name`, `--new` and `--note`. The image verbs take `--folder` while every other verb takes a positional folder. Fix: one positional `folder` everywhere, `n` renamed `count`, and no short flags for `force`/`new`.
42
+ - **minor** README.md:65: `gh skill install` is not a command in the local gh (2.72: unknown command "skill"). Fix: verify the gh version it needs, or document the copy or symlink install.
43
+ - **minor** penult/store.py:63-64: penult invents article's file locations (`~/.config/article/.env`, `~/.local/share/article/pipeline_state.json`), while article's own defaults are `.env` and `pipeline_state.json` in the cwd: two SSOTs, and drafts made with `article` directly are not seen for `--update`. Fix: pass `-e`/`-s` only when the preference is set (default `None`).
44
+ - **minor** penult/tools.py:225: ductus is given the raw file, so the front matter and the image syntax are gauged as prose (segment 0 is the front matter). Fix: feed `markdown_to_fields(...)["content_markdown"]` on stdin (`ductus gauge -`).
45
+ - **minor** penult/tools.py:259: penlike's lines already start with `- `, so check.md shows `- - colons per 100 words`. Fix: strip a leading `- ` before re-bulleting.
46
+ - **minor** penult/tools.py:190: the injected runner is called with no timeout, and `_tool_json` checks `shutil.which` before the runner, so `check` cannot be tested with fakes; it has no test. Fix: probe through the runner and add a fake-JSON test for both tools.
47
+ - **minor** penult/workspace.py:88-91: `is_thin` looks for a thesis in the body only (not the title or comments) and counts any `x. ` (`e.g. `, a URL followed by a space) as one. It counts pasted logs or code as words, and after `start --force` it counts penult's own posted drafts, so a refetched seed is never thin. A `None` body raises TypeError. Fix: drop comments by the penult-posted `comment_url` author/id, and strip code and URLs before counting.
48
+ - **minor** penult/workspace.py:186-206: on refetch, seed.md ("the point of departure, kept as it was") absorbs penult's own draft comments. Fix: exclude the comment(s) recorded in state, or never rewrite seed.md (write `seed-<date>.md`).
49
+ - **minor** penult/tools.py:113: `start` writes seed.md and brief.md into an existing `docs/<slug>/` that is not a penult workspace (no refusal); the essays repo already has a `docs/`. Fix: refuse when the folder exists and is non-empty without penult.json.
50
+ - **minor** penult/images.py:139-140: the suffix comes from the URL path (`.png` when it has none, whatever the bytes are), and an existing `img/<name>` is silently overwritten. Fix: sniff the content type, and refuse to overwrite without `force`.
51
+ - **minor** penult/images.py:188-192: a generator that is billed and then fails (`image.save` raising) leaves no ledger row, so the spend is not counted against the budget. Fix: record a `generated-failed` row in a `finally`, counted toward the budget.
52
+ - **minor** penult/tools.py:435-448: `publish --platform medium --dry-run` skips the approval gate and runs the real `syndicate-secondary` (harmless today, since article's Medium step sends nothing). The README and skill promise an "unpublished Medium draft" where article only prints Import-a-story steps. Fix: refuse `--dry-run` for medium, and reword the claim.
53
+ - **minor** penult/tools.py:380-381: outside a GitHub checkout, `post` skips the push check silently. Detached HEAD gives `branch == "HEAD"` raw URLs. Fix: refuse (or warn) in both cases unless `--raw-base` was given.
54
+ - **minor** penult/tools.py:401: `approve --by` defaults to "the user", so the gate records no one. Fix: make `by` required.
55
+ - **minor** pyproject.toml:81-84: ruff selects only D100, so "All checks passed" says nothing. Fix: `select = ["D100", "F", "B", "UP", "SIM"]`.
56
+ - **minor** tests/test_smoke.py:194: the import check omits `pydantic`, `article` and `dol`. None of them load today (verified: `import penult` pulls none of cw/illustration/aix/ductus/penlike/pydantic/article/dol; `penult.__main__` adds only cw), but nothing keeps it that way. Fix: add them to the tuple.
57
+
58
+ ## Answers to the brief, in one line each
59
+
60
+ - Q3 surfaces: no optional or surface package is imported by `import penult` (verified); `cw` is a core dependency used only by the CLI (acceptable for a console script); the gap is `article[substack]` for `publish` (major above).
61
+ - Q5 skill: every `penult` command it names is a real verb with real flags; penlike (`guide`, `guide-add --source`, `brief --like --style`, `check`) and `ductus gauge` exist. It is unsafe only through "commit and push the workspace" (blocker above) and the public-review claim (major above); budget overspend needs `--force`, which the skill forbids without the author's say-so.
@@ -0,0 +1,4 @@
1
+ # Roadmap
2
+
3
+ - v1 (2026-11-06): the path from an issue to a posted draft and an unpublished Substack draft, with the `penult` skill. See `design.md`.
4
+ - Next, when a consumer asks: a second seed type (a Markdown file, a Discussion) behind the `fetch` seam; MCP over `TOOLS` (no core change needed); a Medium path that does not wait for the Substack post.
@@ -0,0 +1,56 @@
1
+ """penult: from an idea to a publication draft, posted where its author can review it.
2
+
3
+ The seed is a GitHub issue. penult fetches it into a workspace next to it, holds the
4
+ brief, the research notes, the essay, the images and their credits, checks the draft
5
+ with ductus and penlike, renders it as an issue comment, and, once the author says
6
+ so, hands it to ``article`` for an unpublished Substack or Medium draft. The
7
+ thinking in between (research, drafting, the style passes) is an agent's, laid out
8
+ in the ``penult`` skill; the verbs here keep every step from being improvised twice.
9
+
10
+ >>> import penult
11
+ >>> penult.prefs(files={})["preferences"]["platform"]
12
+ 'substack'
13
+ """
14
+
15
+ from penult.base import STAGES, PenultError
16
+ from penult.store import DEFAULT_PREFERENCES, data_dir, preferences
17
+ from penult.tools import (
18
+ TOOLS,
19
+ approve,
20
+ check,
21
+ image_add,
22
+ image_generate,
23
+ image_search,
24
+ mark,
25
+ post,
26
+ prefs,
27
+ prefs_set,
28
+ publish,
29
+ render,
30
+ start,
31
+ status,
32
+ )
33
+ from penult.workspace import Workspace
34
+
35
+ __all__ = [
36
+ "DEFAULT_PREFERENCES",
37
+ "STAGES",
38
+ "TOOLS",
39
+ "PenultError",
40
+ "Workspace",
41
+ "approve",
42
+ "check",
43
+ "data_dir",
44
+ "image_add",
45
+ "image_generate",
46
+ "image_search",
47
+ "mark",
48
+ "post",
49
+ "preferences",
50
+ "prefs",
51
+ "prefs_set",
52
+ "publish",
53
+ "render",
54
+ "start",
55
+ "status",
56
+ ]
@@ -0,0 +1,72 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+ """``penult`` on the command line: every verb of :data:`penult.tools.TOOLS`.
3
+
4
+ penult start https://github.com/<owner>/essays/issues/30
5
+ penult status docs/<slug>
6
+ penult check docs/<slug>
7
+ penult image-search "a 1990s modem" --folder docs/<slug>
8
+ penult render docs/<slug> && penult post docs/<slug>
9
+ penult approve docs/<slug> --note "looks good, ship it"
10
+ penult publish docs/<slug>
11
+
12
+ ``--json`` anywhere prints the whole result instead of its text. The exit status is 0
13
+ when the result is ok and 1 when it is not.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import functools
19
+ import json
20
+ import sys
21
+
22
+ import cw
23
+
24
+ from penult import tools
25
+ from penult.base import PenultError
26
+
27
+
28
+ def _command(func):
29
+ func = tools.without(func)
30
+
31
+ @functools.wraps(func)
32
+ def command(*args, **kwargs):
33
+ try:
34
+ return func(*args, **kwargs)
35
+ except PenultError as error:
36
+ raise cw.CommandError(str(error)) from error
37
+
38
+ return command
39
+
40
+
41
+ def _egress(as_json):
42
+ def egress(result, *, out, err):
43
+ if as_json or not isinstance(result, dict):
44
+ print(
45
+ json.dumps(result, indent=2, ensure_ascii=False, default=str), file=out
46
+ )
47
+ else:
48
+ print(result.get("text") or result.get("summary") or "", file=out)
49
+ return 0 if not isinstance(result, dict) or result.get("ok", True) else 1
50
+
51
+ return egress
52
+
53
+
54
+ def main(argv=None):
55
+ """Run the command line. The ``penult`` console script."""
56
+ for stream in (sys.stdout, sys.stderr):
57
+ getattr(stream, "reconfigure", lambda **_: None)(errors="backslashreplace")
58
+ argv = list(sys.argv[1:] if argv is None else argv)
59
+ as_json = "--json" in argv
60
+ commands = {f.__name__.replace("_", "-"): _command(f) for f in tools.TOOLS}
61
+ code = cw.dispatch(
62
+ commands,
63
+ [a for a in argv if a != "--json"],
64
+ prog="penult",
65
+ convention=cw.MODERN,
66
+ egress=_egress(as_json),
67
+ )
68
+ raise SystemExit(code)
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()