Skip to content

feat: Substack control surface — five nouns over stdlib HTTP and webglass (v0.10.0) - #5

Merged
OriNachum merged 48 commits into
mainfrom
feat/substack-surface
Sep 13, 2026
Merged

OriNachum merged 48 commits into
mainfrom
feat/substack-surface

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

What

The v1 Substack control surface: five nouns (account, post, comment, reaction, feed), 24 command paths, every verb --json. Public read verbs (post list/get, comment list, reaction list) run over stdlib HTTP with no session. Owner verbs (post publish/schedule/unpublish/delete, comment reply/delete, reaction add/remove, feed read, account whoami) go through the sibling webglass binary as a subprocess and need SUBSTACK_WEBGLASS_SESSION. Runtime dependencies stay []; nothing imports a browser-automation library.

Every endpoint the CLI calls was observed in the owner's logged-in browser on 2026-09-13 and is documented in docs/api/substack-endpoints.md; unobserved paths are marked as such and do not ship. Publish is draft-first: without --send only a draft is created; --send --no-email publishes to the site without emailing subscribers; --send alone emails every subscriber and warns on stderr first.

How it was built

devague flow end to end: /scope/think/challenge/spec-to-plan/assign-to-workforce (17 tasks, 7 waves, one worktree per task, TDD-gated merges) → /deviate/validate-delivery/summarize-delivery. Artifacts in this PR:

  • spec docs/specs/2026-09-12-substack-domain-surface.md
  • plan docs/plans/2026-09-12-substack-domain-surface.md + approved split …-split.md
  • endpoint capture docs/api/substack-endpoints.md
  • evidence docs/plans/evidence/baseline-learn.txt, proof-public.txt
  • delivery summary docs/deliveries/2026-09-12-substack-domain-surface.md — the review map: every plan task accounted for, four approved deviations (Workforce run: deviations from the substack-domain-surface plan (d1-d3) #4), 15 self-reported lapses, and a validation ledger with one recorded failure

Status: partial run

16 of 17 plan tasks delivered. t18 (owner-half live proof) is blocked: webglass-cli 0.8.3 cannot create an authenticated persistent session or issue a request from one (agentculture/webglass-cli#17). Owner verbs therefore exit 2 with a hint until that lands; the public half is proven live against two publication hosts.

Known, recorded, not fixed here: comment list on an unknown post exits 2 where post get and reaction list map a 404 to exit 1 (delta b3).

Gates

  • uv run pytest -n auto: 329 passed, 1 pre-existing skip; coverage 96 % (floor 60 enforced in CI)
  • black, isort, flake8, bandit, scan-secrets, markdownlint, teken cli doctor . --strict, harness-smoke config: clean
  • version 0.9.1 → 0.10.0, CHANGELOG entry
  • README and all four harness prompts updated together; .pi/SYSTEM.md, doctor.py, vendored skills, publish workflow and Sonar config untouched (asserted by tests/test_repo_invariants.py)

🤖 Generated with Claude Code

https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV

  • substack-cli (Claude)

OriNachum and others added 30 commits September 12, 2026 20:38
`/init` replaced the scaffold seed `CLAUDE.md` with a runtime prompt grounded
in this repo, and the other three harness files were brought in line with it —
each written for its own harness rather than restating the same text.

- CLAUDE.md: command block (tests, single test, every lint/rubric/smoke gate),
  the cross-file CLI wiring (`_CliArgumentParser.error()` override, the
  `_json_hint` argv peek, the `parser_class` propagation trap, `CliError`, the
  stdout/stderr split, the catalog walked by `tests/test_cli.py`), a five-place
  checklist for adding a Substack noun, and `_PROMPT_FILE` vs
  `_RESIDENT_PROMPT`.
- QWEN.md: the coding-session machinery for a Qwen Code session.
- AGENTS.override.md: context only (identity stays in `.pi/SYSTEM.md`), and
  tells Pi's non-coding lane to report the scaffold gap rather than infer an
  implementation from the project description.
- AGENTS.colleague.md: rewritten around colleague as the `/ask-colleague`
  second mind — per-verb expectations, the contracts a diff must respect, and
  the colleague#494 empty-skills caveat.
- README.md: template-clone instructions replaced by this repo's own Status /
  CLI / Development sections.

Fixes four drifts the docs carried: a Substack surface (posts, comments,
statistics, subscribers) that does not exist on disk; a quickstart using
`uv run substack-cli …` when `[project.scripts]` installs the binary as
`substack`; a skill count of 11 against 19 on disk; and three files calling
`CLAUDE.md` "the fullest write-up" while it was still the `/init` seed.

Verified: 117 tests pass, `teken cli doctor . --strict` PASS,
`harness-smoke --stage config` 6/6 PASS, scan-secrets clean, markdownlint
clean, `devex pr lint` no violations. Qwen and Pi were asked "Who are you,
and what can you do?" in this tree and both answered from their new files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VaCYnmwSaYL2DLxDtd1fTs
The Worktrees section claimed the vendored skill's fan-out example uses a
shared `../worktrees/` path that must be overridden. It does not: the current
`.claude/skills/assign-to-workforce/SKILL.md` mandates the same repo-named
`.worktrees.<repo-name>` root this file does, and explicitly says "Never use a
bare `../worktrees/`". The claim was inherited from culture-agent-template's
CLAUDE.md and describes an older revision of the vendored file.

Only the branch-name half of the override still applies (`agent/<task-id>`).

Found by `ask-colleague review` on this PR. Its other two findings: the
"eight skills originate in devague" phrasing is correct as written
(docs/skill-sources.md lists exactly eight devague-origin rows, all
re-broadcast via guildmaster — the direct-from-devague vendoring it recalled
is marked superseded in that ledger), and the code-side "clonable template"
strings are filed as #3 rather than folded into a doc-only PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VaCYnmwSaYL2DLxDtd1fTs
…ecision

Six inline findings from the Qodo review on PR #2, each verified against disk
before acting:

- Mark the Substack surface `(planned)` at first mention in all four harness
  files and on the "Adding the Substack surface" heading, per this repo's own
  rule that anything ahead of disk carries the marker.
- Note that `markdownlint-cli2` is an npm prerequisite, not something `uv sync`
  installs — CI installs it separately, so a clean checkout following the
  documented sequence stopped there.
- Stop implying all four harnesses load the shared skill tree: the symlinks are
  shared wiring, but colleague 1.76.0 loads 0 of the 19 (colleague#494). The
  caveat was in AGENTS.colleague.md only; it is now in CLAUDE.md and QWEN.md
  where the "one tree, four loaders" claim was made.
- Qualify the CliError contract. "Every failure raises CliError" contradicted
  two deliberate paths: `_CliArgumentParser.error()` emits a CliError then
  raises SystemExit, and `doctor` returns 1 for an unhealthy report rather than
  raising. Both are now named so neither gets "fixed" by a future change.
- AGENTS.colleague.md claimed nothing from a read-only run reaches the asker's
  checkout. Tracked files, correct — but the run artifact is copied into the
  gitignored `.colleague/` dir for grading. Stated.
- docs/skill-sources.md listed 17 rows while `.claude/skills/` holds 19:
  `recall` and `remember` were vendored without ledger entries. Added both
  (guildmaster, scope literal adapted), plus a header line accounting for all
  19. Qodo read the gap as "guildmaster does not supply these two" — it does
  ship both; the defect was the missing rows, not the supplier.

Also corrected the kit description in CLAUDE.md, QWEN.md and README.md: 19
skills is 17 guildmaster + ask-colleague direct from colleague, not a single
"canonical guildmaster kit".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VaCYnmwSaYL2DLxDtd1fTs
Converged frame for the Substack control surface: post/feed/comment/
reaction/account nouns, webglass-cli as the runtime browser plane,
public read verbs first, owner verbs gated on webglass authenticated
sessions (agentculture/webglass-cli#17).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Rigorous pass (hard-to-reverse publish + security-sensitive session):
webglass has no network lens (capture via Chrome-MCP now, lens asked in
webglass-cli#17), two API hosts, markdown->ProseMirror is real work, no
auto-retry on writes, partial-state reporting, three-state auth probe,
untrusted third-party text, containment twins for every write verb,
draft-first publish with --no-email for live proofs. One self-reported
lapse (l1) confirmed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
17 tasks in 7 waves covering all 56 spec targets: baseline, HTTP
transport, webglass adapter, untrusted-text helper, Chrome capture;
five nouns; post write side + markdown body; parser/learn/catalog
wiring; docs, version bump, CI gates; public and owner live proofs.
Owner proof (t18) is a follow-up risk on webglass-cli#17.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
# Conflicts:
#	substack_cli/substack/__init__.py
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Captured 2026-09-13 in the owner's logged-in browser on
jetsonailab.substack.com: drafts create/update/publish/schedule/
unschedule/unpublish/delete, comments create/reply/delete, post and
comment reactions add/remove, public archive/post/comments reads, the
subscription endpoint as the whoami source, and the substack.com reader
feed. Unobserved paths are marked as such.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
# Conflicts:
#	substack_cli/substack/http.py
…own body builder

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
OriNachum and others added 12 commits September 13, 2026 05:12
# Conflicts:
#	substack_cli/substack/http.py
#	tests/fakes/webglass/webglass
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
The live public-read proof returned 403: Substack rejects urllib's
default Python-urllib agent (a descriptive substack-cli/<version> agent
is accepted; verified with curl). The GET loop also retried every
HTTPError; it now retries only 429, 5xx and transport errors, per
spec claim c38. Two tests that asserted four attempts on a 404 now
assert one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
… prompts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Evidence stored as a text record with a header line (JSON body):
scripts/scan-secrets.py fails any JSON-parsable file whose url keys
point off localhost, and the t2 invariant forbids tracked *.json files
naming substack.com.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
Partial run: 16 of 17 plan tasks delivered, t18 (owner-half live proof)
blocked on agentculture/webglass-cli#17. Deviations d1-d4, lapses
l1-l15, validation ledger o1-o23/e1-e22/b1-b4 all adjudicated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
@OriNachum

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add five-noun Substack control surface via HTTP and webglass

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Adds five Substack nouns with public reads and session-backed owner operations.
• Introduces stdlib HTTP transport, webglass isolation, rendering, and Markdown conversion.
• Documents observed endpoints, blocked owner proof, and comprehensive test coverage.
Diagram

sequenceDiagram
    actor User as Agent
    participant CLI as CLI Parser
    participant Commands as Noun Commands
    participant HTTP as Stdlib HTTP
    participant WG as Webglass Adapter
    participant Binary as Webglass CLI
    participant API as Substack API
    User->>CLI: Invoke noun verb
    CLI->>Commands: Dispatch arguments
    alt Public read
        Commands->>HTTP: GET request
        HTTP->>API: Anonymous API call
        API-->>HTTP: JSON response
        HTTP-->>Commands: Parsed payload
    else Owner operation
        Commands->>WG: Authenticated request
        WG->>Binary: Subprocess with session
        Binary->>API: Session-backed API call
        API-->>Binary: HTTP response
        Binary-->>WG: WebOperationResult
        WG-->>Commands: Result or CliError
    end
    Commands-->>CLI: Structured result
    CLI-->>User: stdout or stderr
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Ship public reads before owner verbs
  • ➕ Avoids publishing a user-facing surface backed by a nonexistent webglass request capability.
  • ➕ Keeps release claims aligned with functionality proven against live Substack hosts.
  • ➕ Allows the adapter contract to follow the eventual upstream protocol instead of anticipating it.
  • ➖ Delays the complete five-noun control surface.
  • ➖ Requires a later release to add and document owner operations.
  • ➖ Reduces early integration coverage for write workflows.
2. Use direct authenticated HTTP
  • ➕ Could make owner operations usable without waiting for webglass-cli.
  • ➕ Would simplify response parsing by avoiding the subprocess result envelope.
  • ➖ Would require this process to handle browser cookies or credentials.
  • ➖ Violates the intended browser-automation isolation and zero-runtime-dependency boundary.
  • ➖ Introduces substantial security, session-lifecycle, and compliance risk.

Recommendation: Keep the public-HTTP/webglass separation: it preserves credential isolation, avoids browser dependencies, and centralizes the future upstream integration. However, treat owner verbs as explicitly unavailable or experimental until webglass-cli provides and validates its authenticated request contract; direct cookie handling is not worth the security tradeoff.

Files changed (52) +11373 / -241

Enhancement (11) +2109 / -1
__init__.pyRegister the five Substack noun groups +14/-1

Register the five Substack noun groups

• Updates the parser description and wires account, post, comment, reaction, and feed modules into the top-level CLI.

substack_cli/cli/init.py

account.pyAdd account identity and webglass diagnostics +219/-0

Add account identity and webglass diagnostics

• Implements 'account whoami' using subscription and publication probes through webglass. Adds a non-failing overview that reports webglass presence and version.

substack_cli/cli/_commands/account.py

comment.pyAdd comment listing, replies, and deletion +219/-0

Add comment listing, replies, and deletion

• Implements public comment listing plus authenticated top-level or threaded replies and deletion. Routes untrusted bodies through the shared renderer and owner writes through webglass.

substack_cli/cli/_commands/comment.py

feed.pyAdd authenticated account feed reading +189/-0

Add authenticated account feed reading

• Implements home and following feed sources, cursor handling, opaque item normalization, and safe text rendering through webglass-backed requests.

substack_cli/cli/_commands/feed.py

post.pyAdd public and owner post operations +447/-0

Add public and owner post operations

• Implements archive listing, slug lookup, draft creation, publishing, scheduling, unpublishing, and deletion. Publishing is draft-first, warns before subscriber email, and reports partial state if the publish step fails.

substack_cli/cli/_commands/post.py

reaction.pyAdd reaction listing and mutation commands +238/-0

Add reaction listing and mutation commands

• Implements public aggregate reaction counts and authenticated add/remove operations for posts or comments, defaulting to the observed heart reaction.

substack_cli/cli/_commands/reaction.py

__init__.pyCreate the Substack domain package +1/-0

Create the Substack domain package

• Adds the package marker for shared Substack transport, body, rendering, and webglass modules.

substack_cli/substack/init.py

body.pyConvert restricted Markdown to ProseMirror +234/-0

Convert restricted Markdown to ProseMirror

• Adds a fail-closed stdlib converter for headings, paragraphs, inline marks, lists, links, and standalone images. Unsupported Markdown produces a user error directing callers to '--body-json'.

substack_cli/substack/body.py

http.pyAdd the stdlib Substack HTTP transport +257/-0

Add the stdlib Substack HTTP transport

• Introduces publication and account API bases, hostname validation, injectable urllib access, a descriptive user agent, and selective GET backoff. State-changing requests are sent once without retries.

substack_cli/substack/http.py

render.pyAdd safe rendering for untrusted content +56/-0

Add safe rendering for untrusted content

• Centralizes text and JSON rendering so author-supplied content remains under a dedicated field and never enters diagnostics or stderr.

substack_cli/substack/render.py

webglass.pyAdd the authenticated webglass subprocess adapter +235/-0

Add the authenticated webglass subprocess adapter

• Validates webglass availability and session configuration, invokes JSON subprocess operations, and maps HTTP-shaped or backend failures to CLI exit semantics. The request verb is isolated pending upstream support.

substack_cli/substack/webglass.py

Tests (16) +4022 / -0
conftest.pyBlock real network access during tests +41/-0

Block real network access during tests

• Adds an autouse fixture that rejects socket connections, ensuring all HTTP behavior uses test doubles.

tests/conftest.py

__init__.pyCreate the test-fakes package +1/-0

Create the test-fakes package

• Adds the package marker for reusable HTTP and subprocess test doubles.

tests/fakes/init.py

http.pyAdd an in-memory urllib test double +88/-0

Add an in-memory urllib test double

• Provides queued fake responses and request recording for deterministic transport, retry, header, method, and URL assertions.

tests/fakes/http.py

webglassAdd a configurable fake webglass executable +127/-0

Add a configurable fake webglass executable

• Emits canned, URL-selected, or sequenced WebOperationResult payloads and records subprocess arguments for multi-step owner-flow tests.

tests/fakes/webglass/webglass

test_account.pyTest account authentication states and overview +376/-0

Test account authentication states and overview

• Covers parser behavior, missing and expired sessions, successful identity reports, malformed responses, invalid hosts, and webglass availability reporting.

tests/test_account.py

test_body.pyTest Markdown-to-ProseMirror conversion +207/-0

Test Markdown-to-ProseMirror conversion

• Covers supported block and inline constructs, JSON serialization, list and image shapes, and fail-closed errors for unsupported syntax.

tests/test_body.py

test_comment.pyTest comment read and owner workflows +502/-0

Test comment read and owner workflows

• Covers registration, overview, empty and hostile content, reply payloads, parent threading, deletion, session failures, 404 mapping, and no-retry behavior.

tests/test_comment.py

test_feed.pyTest home and following feed behavior +313/-0

Test home and following feed behavior

• Covers source selection, pagination, item normalization, text rendering, empty results, session requirements, and anonymous failures.

tests/test_feed.py

test_nouns_wired.pyVerify complete noun and documentation wiring +155/-0

Verify complete noun and documentation wiring

• Asserts all five nouns and 24 paths are registered, accept JSON, appear in learning output, and exclude unimplemented subscriber or statistics paths.

tests/test_nouns_wired.py

test_post.pyTest public post reads +232/-0

Test public post reads

• Covers parser registration, archive pagination, item mapping, hostname validation, successful slug lookup, and 404 versus server-error handling.

tests/test_post.py

test_post_write.pyTest draft, publish, and containment workflows +661/-0

Test draft, publish, and containment workflows

• Exercises draft-first publishing, email flags and warnings, partial failures, body sources, scheduling, unpublishing, deletion, missing sessions, and single-attempt writes.

tests/test_post_write.py

test_reaction.pyTest reaction reads and mutations +613/-0

Test reaction reads and mutations

• Covers aggregate counts, target exclusivity, heart defaults, custom emoji payloads, post and comment URLs, session failures, error mapping, and subprocess call shapes.

tests/test_reaction.py

test_render.pyTest untrusted-content rendering +91/-0

Test untrusted-content rendering

• Verifies text and JSON layouts, hostile-content isolation from stderr, multi-item output, empty results, and custom output streams.

tests/test_render.py

test_repo_invariants.pyProtect runtime and repository boundaries +98/-0

Protect runtime and repository boundaries

• Asserts no browser-automation references or interactive input enter runtime code, dependencies remain empty, and protected identity and release files stay unchanged.

tests/test_repo_invariants.py

test_substack_http.pyTest HTTP bases, validation, and retry policy +229/-0

Test HTTP bases, validation, and retry policy

• Covers environment overrides, host validation, account routing, headers, credential absence, retryable GET failures, non-retryable errors, and one-shot writes.

tests/test_substack_http.py

test_webglass_adapter.pyTest webglass subprocess and failure mapping +288/-0

Test webglass subprocess and failure mapping

• Covers prerequisite checks, JSON invocation, malformed output, authenticated request arguments, and mapping of 401, 404, backend, and lifecycle failures.

tests/test_webglass_adapter.py

Documentation (20) +5117 / -205
currentSelect the Substack surface workflow +1/-0

Select the Substack surface workflow

• Points devague at the 'substack-domain-surface' workflow.

.devague/current

current_planSelect the active Substack implementation plan +1/-0

Select the active Substack implementation plan

• Records 'substack-domain-surface' as the current devague plan.

.devague/current_plan

substack-domain-surface.jsonRecord machine-readable delivery evidence +543/-0

Record machine-readable delivery evidence

• Captures delivered tasks, approved deviations, validation evidence, lapses, and blocked owner-side proof for the devague run.

.devague/deliveries/substack-domain-surface.json

substack-domain-surface.jsonCapture the machine-readable feature frame +1555/-0

Capture the machine-readable feature frame

• Stores the requirements, boundaries, honesty conditions, research, and decisions behind the Substack control surface.

.devague/frames/substack-domain-surface.json

substack-domain-surface.jsonAdd the machine-readable implementation plan +832/-0

Add the machine-readable implementation plan

• Defines plan targets, task dependencies, acceptance criteria, and delivery waves for the five-noun surface.

.devague/plans/substack-domain-surface.json

AGENTS.colleague.mdAlign colleague guidance with the implemented CLI +133/-23

Align colleague guidance with the implemented CLI

• Reframes colleague as an independent review and exploration assistant, documents the live Substack nouns, and records the contracts reviewers should enforce.

AGENTS.colleague.md

AGENTS.override.mdUpdate Pi context for the Substack surface +82/-31

Update Pi context for the Substack surface

• Replaces template-oriented context with the implemented noun surface, transport boundaries, limitations, and non-coding Pi responsibilities.

AGENTS.override.md

CHANGELOG.mdDocument the 0.10.0 feature release +50/-0

Document the 0.10.0 feature release

• Adds the five nouns, supporting transport and rendering modules, endpoint documentation, and the upstream webglass limitation to the changelog.

CHANGELOG.md

CLAUDE.mdReplace the bootstrap prompt with repository guidance +263/-19

Replace the bootstrap prompt with repository guidance

• Expands the seed into a full Claude Code prompt covering commands, architecture, CLI contracts, testing gates, workflow rules, and the implemented Substack surface.

CLAUDE.md

QWEN.mdAlign Qwen guidance with the implemented surface +145/-55

Align Qwen guidance with the implemented surface

• Updates Qwen's project context, development workflow, architecture, command contracts, and webglass limitation for the current repository.

QWEN.md

README.mdDocument the five-noun CLI and operational risks +145/-54

Document the five-noun CLI and operational risks

• Adds status, quickstart, complete command table, transport design, one-time login guidance, and an explicit Substack Terms-of-Service warning. It also records that owner operations remain blocked upstream.

README.md

substack-endpoints.mdDocument observed Substack API contracts +118/-0

Document observed Substack API contracts

• Records captured methods, paths, payloads, response shapes, authentication requirements, and provenance for every shipped endpoint. Unobserved operations are explicitly excluded.

docs/api/substack-endpoints.md

2026-09-12-substack-domain-surface.mdSummarize delivery status and remaining work +160/-0

Summarize delivery status and remaining work

• Maps all plan tasks to delivered artifacts, deviations, evidence, confidence levels, known lapses, and follow-up work. It identifies the blocked owner proof and inconsistent comment-list 404 behavior.

docs/deliveries/2026-09-12-substack-domain-surface.md

2026-09-12-substack-domain-surface-split.mdAdd the seven-wave workforce split +222/-0

Add the seven-wave workforce split

• Documents task assignments, dependency waves, acceptance criteria, terminal tasks, and unresolved risks for parallel implementation.

docs/plans/2026-09-12-substack-domain-surface-split.md

2026-09-12-substack-domain-surface.mdAdd the Substack surface build plan +164/-0

Add the Substack surface build plan

• Defines 17 implementation and validation tasks covering transports, nouns, documentation, CI gates, and live proof.

docs/plans/2026-09-12-substack-domain-surface.md

baseline-learn.txtCapture the pre-feature CLI baseline +1/-0

Capture the pre-feature CLI baseline

• Preserves the 0.9.1 'learn --json' output showing the original six scaffold commands.

docs/plans/evidence/baseline-learn.txt

skill-sources.mdComplete the vendored skill provenance ledger +7/-0

Complete the vendored skill provenance ledger

• Corrects the skill count to 19 and documents the previously unlisted 'recall' and 'remember' sources and local adaptations.

docs/skill-sources.md

2026-09-12-substack-domain-surface.mdSpecify the five-noun control surface +219/-0

Specify the five-noun control surface

• Defines requirements, security and authentication boundaries, success signals, endpoint assumptions, research findings, and non-goals for v1.

docs/specs/2026-09-12-substack-domain-surface.md

learn.pyTeach the CLI about all new command paths +157/-18

Teach the CLI about all new command paths

• Replaces scaffold wording and adds all noun verbs, authentication tiers, and owner-session limitations to text and JSON learning output.

substack_cli/cli/_commands/learn.py

catalog.pyDocument every noun and verb in explain +319/-5

Document every noun and verb in explain

• Adds catalog entries and usage guidance for all account, post, comment, reaction, and feed paths while removing template framing.

substack_cli/explain/catalog.py

Other (5) +125 / -35
tests.ymlEnforce the coverage floor in CI +1/-1

Enforce the coverage floor in CI

• Adds '--cov-fail-under=60' to the pytest coverage command so CI fails below the documented threshold.

.github/workflows/tests.yml

.gitignoreIgnore transient devague review state +4/-0

Ignore transient devague review state

• Excludes local devague review and question directories from version control.

.gitignore

proof-public.txtRecord public endpoint live proof +86/-0

Record public endpoint live proof

• Captures sessionless command results against two publication hosts, including successful archive reads and observed 404 behavior.

docs/plans/evidence/proof-public.txt

pyproject.tomlBump the package version to 0.10.0 +1/-1

Bump the package version to 0.10.0

• Advances the project version for the new minor feature release while retaining an empty runtime dependency list.

pyproject.toml

uv.lockRefresh the lockfile for version 0.10.0 +33/-33

Refresh the lockfile for version 0.10.0

• Updates the editable project package entry from 0.9.0 to 0.10.0 without adding runtime dependencies.

uv.lock

@qodo-code-review

qodo-code-review Bot commented Sep 13, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Owner commands always fail ✓ Resolved 🐞 Bug ≡ Correctness
Description
webglass.request invokes _REQUEST_VERB = "request", even though the adapter documents that this
verb does not exist in the available webglass command-line tool. Post writes, comment writes,
reactions, feed reads, and account identity checks all reach this call, so a configured owner cannot
perform any of those advertised operations.
Code

substack_cli/substack/webglass.py[R229-234]

+    args = [_REQUEST_VERB, "--session-id", session_id, "--method", method, "--url", url]
+    if json_body is not None:
+        args += ["--json-body", json.dumps(json_body)]
+
+    result = run_webglass(args)
+    map_failure(result)
Evidence
The adapter itself declares the selected subprocess verb nonexistent, while its single request entry
point still constructs and executes that verb. All owner-side commands depend on this entry point,
and the included fake merely returns canned JSON without validating its command-line arguments.

substack_cli/substack/webglass.py[41-44]
substack_cli/substack/webglass.py[214-235]
substack_cli/cli/_commands/post.py[229-252]
substack_cli/cli/_commands/comment.py[113-143]
substack_cli/cli/_commands/reaction.py[119-149]
substack_cli/cli/_commands/account.py[120-134]
substack_cli/cli/_commands/feed.py[109-129]
tests/fakes/webglass/webglass[95-123]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

Issue description
The owner command surface invokes a webglass verb which this PR documents as unavailable, causing every owner operation to fail even with a valid session.

Fix Focus Areas
- substack_cli/substack/webglass.py[44-44]
- substack_cli/substack/webglass.py[229-234]

Recommended Fix
Do not register or advertise owner verbs until webglass supports authenticated requests, or implement the adapter against an available authenticated webglass interface and prove it against the real binary. Ensure the fallback message explicitly identifies unsupported webglass request capability if temporary exposure is required.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Comment lists omit thread replies ✗ Dismissed 🐞 Bug ≡ Correctness
Description
cmd_comment_list requests the comments endpoint without all_comments, sorting, or pagination
parameters and renders only the top-level comments array without traversing each comment's
children. Replies nested under the documented children field or excluded from the endpoint's
default page never reach either text or JSON output.
Code

substack_cli/cli/_commands/comment.py[R72-74]

+    raw = http.get_json(args.publication, f"post/{args.post}/comments")
+    comments = raw.get("comments", []) if isinstance(raw, dict) else []
+    render_items([_to_render_item(item) for item in comments], json_mode=json_mode)
Evidence
The command maps only the immediate comments array, while the captured endpoint contract
explicitly records complete-thread query parameters and nested children on comment objects.

substack_cli/cli/_commands/comment.py[70-74]
docs/api/substack-endpoints.md[70-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`comment list` does not request the complete thread and does not traverse nested comment children, so replies can be omitted from its output.

## Fix Focus Areas
- substack_cli/cli/_commands/comment.py[70-74]
- docs/api/substack-endpoints.md[70-80]

## Recommended Fix
Add the observed `all_comments=true`, `sort=best_first`, and cursor parameters using proper query encoding, implement pagination when a continuation cursor is returned, and recursively flatten documented `children` entries before rendering.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Reaction writes can hit production ✗ Dismissed 🐞 Bug ≡ Correctness
Description
_reaction_url hardcodes https://<host>/api/v1 instead of using the environment-aware publication
base used by the other owner commands. When SUBSTACK_API_BASE points at a local or staging
service, reaction reads use that override but add and remove operations bypass it and target the
real publication host.
Code

substack_cli/cli/_commands/reaction.py[R61-62]

+def _reaction_url(host: str, target: str, target_id: str) -> str:
+    return f"https://{host}/api/v1/{target}/{target_id}/reaction"
Evidence
The reaction helper embeds the production scheme and API prefix, while the shared base builder
explicitly supports SUBSTACK_API_BASE and is already designated for owner-side webglass requests.

substack_cli/cli/_commands/reaction.py[61-62]
substack_cli/cli/_commands/reaction.py[119-142]
substack_cli/substack/http.py[99-131]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Authenticated reaction writes bypass `SUBSTACK_API_BASE`, potentially sending operations to the real publication while other commands use a configured local or staging endpoint.

## Fix Focus Areas
- substack_cli/cli/_commands/reaction.py[61-62]
- substack_cli/substack/http.py[99-131]

## Recommended Fix
Build reaction endpoint URLs from `http.publication_base(host)` and append the target path, matching the other owner-side command implementations.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View high (1)
4. Publishing can email every subscriber ✗ Dismissed 🐞 Bug ≡ Correctness
Description
cmd_post_publish sets the publish request's send field to true whenever --send is supplied,
although this branch has no observed successful send-email request. The endpoint record explicitly
classifies publishing with send: true as unobserved, so an owner invoking the documented flag can
send a publication-wide email through an unverified destructive path.
Code

substack_cli/cli/_commands/post.py[R243-246]

+                "POST",
+                _api_url(host, f"drafts/{draft_id}/publish"),
+                {"send": send, "saved_segment_id": None},
+            )
Evidence
The implementation directly emits send: true for the email path, whereas the PR's own endpoint
evidence says that path was not exercised and lists it among paths that must not ship.

substack_cli/cli/_commands/post.py[214-225]
substack_cli/cli/_commands/post.py[240-253]
docs/api/substack-endpoints.md[53-66]
docs/api/substack-endpoints.md[106-111]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

Issue description
The publish command sends `send: true` despite the endpoint evidence recording that email publishing was never observed or exercised.

Fix Focus Areas
- substack_cli/cli/_commands/post.py[243-246]

Recommended Fix
Reject `--send` until a successful send-email request has been captured and tested, while retaining the observed web-only `send: false` path. Only re-enable email delivery after documenting the confirmed request contract and adding an integration-proof-compatible test.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Invalid post documents reach draft creation ✓ Resolved 🐞 Bug ≡ Correctness
Description
_draft_body only checks that --body-json parses and then serializes the decoded value unchanged,
without requiring a ProseMirror object whose type is doc and whose content is a list. Inputs
such as null, arrays, scalars, {}, or a paragraph node therefore pass local validation and reach
the draft-create endpoint as draft_body, despite the endpoint requiring a document root.
Code

substack_cli/cli/_commands/post.py[R176-195]

+    if args.body_json:
+        raw = _read_file(args.body_json, "body-json")
+        try:
+            document = json.loads(raw)
+        except json.JSONDecodeError as exc:
+            raise CliError(
+                code=1,
+                message=f"--body-json file {args.body_json!r} is not valid JSON: {exc}",
+                remediation="pass a file containing a ProseMirror document "
+                '({"type": "doc", "content": [...]})',
+            ) from exc
+        return json.dumps(document, ensure_ascii=False)
+    return body.to_draft_body(_read_file(args.markdown, "markdown"))
+
+
+def _current_user_id(host: str) -> int:
+    """Read the signed-in user's id from ``GET <pub>/api/v1/subscription``."""
+    result = webglass.request("GET", _api_url(host, "subscription"))
+    user_id = _response_body(result).get("user_id")
+    if user_id is None:
Evidence
The cited command code performs json.loads and immediately serializes the result into the draft
request without structural validation, while the endpoint documentation identifies the required
ProseMirror root shape and the command’s remediation text confirms that this shape is expected.

substack_cli/cli/_commands/post.py[174-195]
substack_cli/cli/_commands/post.py[221-236]
docs/api/substack-endpoints.md[53-68]
substack_cli/cli/_commands/post.py[174-188]
docs/api/substack-endpoints.md[65-68]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description

`--body-json` validates JSON syntax only, allowing arbitrary decoded values that are not ProseMirror documents to reach the draft endpoint as `draft_body`.

## Fix Focus Areas

- substack_cli/cli/_commands/post.py[174-195]
- docs/api/substack-endpoints.md[65-68]

## Recommended Fix

After decoding `--body-json`, require the value to be an object with `type` equal to `doc` and a list-valued `content` field before serializing it. When validation fails, raise `CliError(1)` with the existing user-facing ProseMirror remediation explaining that `--body-json` must contain a ProseMirror document.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Feed pagination rejects valid cursors ✗ Dismissed 🐞 Bug ≡ Correctness
Description
cmd_feed_read concatenates args.cursor directly into the request query string rather than
encoding it as a query value. A server-provided cursor containing characters such as &, #, +,
or % is reinterpreted or changed when passed back, so the next feed page cannot be retrieved
correctly.
Code

substack_cli/cli/_commands/feed.py[R117-119]

+        url = f"{base}/reader/feed?limit={args.limit}"
+        if args.cursor:
+            url = f"{url}&cursor={args.cursor}"
Evidence
The command exposes nextCursor for callers to reuse but inserts that token verbatim into the next
URL, with no query encoder between response extraction and request construction.

substack_cli/cli/_commands/feed.py[109-134]
docs/api/substack-endpoints.md[95-104]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Opaque feed cursors are concatenated into URLs without query encoding, so valid cursor characters can alter the resulting request.

## Fix Focus Areas
- substack_cli/cli/_commands/feed.py[109-121]

## Recommended Fix
Construct the feed query with `urllib.parse.urlencode`, including both `limit` and `cursor`, rather than concatenating values into the URL.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Owner commands can hang forever ✗ Dismissed 🐞 Bug ☼ Reliability
Description
run_webglass calls subprocess.run without a timeout and catches only OSError. If webglass or
its browser backend stalls, every account, feed, post, comment, and reaction owner operation waits
indefinitely instead of returning the documented environment error.
Code

substack_cli/substack/webglass.py[R99-104]

+        completed = subprocess.run(  # nosec B603 - fixed binary name, args are ours
+            cmd,
+            capture_output=True,
+            text=True,
+            check=False,
+        )
Evidence
The sole synchronous subprocess chokepoint supplies no timeout and has no TimeoutExpired handler;
all authenticated requests pass through this function.

substack_cli/substack/webglass.py[97-111]
substack_cli/substack/webglass.py[214-234]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Every webglass-backed command can block indefinitely because the subprocess invocation has no timeout.

## Fix Focus Areas
- substack_cli/substack/webglass.py[97-111]

## Recommended Fix
Supply a configurable finite timeout to `subprocess.run`, catch `subprocess.TimeoutExpired`, terminate the child as needed, and translate the timeout into `CliError(EXIT_ENV_ERROR)` with a diagnostic remediation message.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (2)
8. Public reads can hang forever ✗ Dismissed 🐞 Bug ☼ Reliability
Description
_get_with_backoff calls opener.open without a timeout, so its retry policy starts only after the
current network attempt returns. A connection or response that remains stalled can therefore block
every public post, comment, or reaction read indefinitely without ever reaching backoff or a
controlled error.
Code

substack_cli/substack/http.py[R185-188]

+        try:
+            with opener.open(request) as response:
+                payload = response.read()
+            return json.loads(payload.decode("utf-8")) if payload else {}
Evidence
The retry loop bounds the number of completed attempts but places no time bound on opener.open, so
a single stalled attempt prevents all subsequent retries and error mapping.

substack_cli/substack/http.py[177-202]
substack_cli/substack/http.py[205-217]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Public HTTP reads can wait indefinitely because individual network attempts have no timeout, making the retry policy ineffective against stalled connections.

## Fix Focus Areas
- substack_cli/substack/http.py[177-202]

## Recommended Fix
Pass a configurable finite timeout to `opener.open`, map timeout failures through the existing retry policy, and return `CliError(2)` after the retry budget is exhausted.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Malformed responses blame the caller ✗ Dismissed 🐞 Bug ☼ Reliability
Description
_get_with_backoff decodes successful response bytes and parses them with json.loads inside
handling that catches only HTTPError and URLError, allowing UnicodeDecodeError and
JSONDecodeError to escape as non-CliError exceptions. A malformed or non-UTF-8 successful
response from the publication or transport therefore reaches the top-level dispatcher, which assigns
exit code 1 to every affected public read command instead of the environment-error code 2.
Code

substack_cli/substack/http.py[R185-189]

+        try:
+            with opener.open(request) as response:
+                payload = response.read()
+            return json.loads(payload.decode("utf-8")) if payload else {}
+        except (urllib.error.HTTPError, urllib.error.URLError) as exc:
Evidence
The HTTP transport decodes and parses JSON at line 188, while the adjacent handler catches only
connection and HTTP-status exceptions, not UnicodeDecodeError or JSONDecodeError. The CLI's
catch-all then maps any escaped exception to the user-error exit code 1, proving that malformed
successful responses bypass the transport's CliError contract and receive the wrong diagnostic and
exit status.

substack_cli/substack/http.py[177-202]
substack_cli/cli/init.py[111-131]
substack_cli/substack/http.py[151-163]
substack_cli/cli/_commands/post.py[81-104]
substack_cli/cli/_commands/comment.py[70-75]
substack_cli/cli/_commands/reaction.py[88-116]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Malformed or non-UTF-8 JSON in a successful HTTP response bypasses the transport's `CliError` handling and is misclassified by the top-level dispatcher as a caller mistake with exit code 1.

## Fix Focus Areas
- substack_cli/substack/http.py[177-202]
- substack_cli/substack/http.py[151-163]
- substack_cli/cli/__init__.py[111-131]

## Recommended Fix
Catch `UnicodeDecodeError` and `json.JSONDecodeError` around response decoding in both read and write transport paths, then raise `CliError(2)` with the request URL and response-format remediation. Do not retry deterministic malformed payloads unless explicitly desired, and add tests for malformed successful response payloads that verify the CLI exits with code 2.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 13 rules
✅ Cross-repo context — repo relationships
  Explored: repo: agentculture/agtag (sha: 039cd34d)
  Explored: repo: agentculture/devague (sha: ec15362a)
  Explored: repo: agentculture/culture (sha: d5b5715f)
  Explored: repo: agentculture/steward (sha: 7b6c16db)
  Explored: repo: agentculture/devex (sha: 70b3c214)
  Explored: repo: agentculture/colleague (sha: 6081b8c8)
  Explored: repo: agentculture/guildmaster (sha: fedb965d)
Review mode: 🧠 Deep: This introduces a broad, security-sensitive CLI surface across multiple independent command paths, HTTP and authenticated subprocess adapters, request/body handling, and destructive publishing operations, making multiple subtle defects plausible.

Grey Divider

Tip of the day
💡 Did you know, you can start a comment with 'qodo' or '@qodo' to chat about any finding

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread substack_cli/cli/_commands/comment.py Outdated
Comment thread substack_cli/cli/_commands/reaction.py Outdated
Comment thread substack_cli/cli/_commands/feed.py Outdated
Comment thread substack_cli/substack/webglass.py
Comment thread substack_cli/substack/http.py Outdated
Comment thread substack_cli/substack/http.py Outdated
Comment thread substack_cli/substack/webglass.py
Comment thread substack_cli/cli/_commands/post.py
Comment thread substack_cli/cli/_commands/post.py
OriNachum and others added 3 commits September 13, 2026 08:34
…nflicts)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
…998832725, 3998832730, 3998832732, 3998832734, 3998832736, 3998832739)

- 3998832718: `comment list` now sends the observed query
  (all_comments=true&sort=best_first, urlencoded) and flattens nested
  `children` replies depth-first, parents before replies, keeping
  parent_id/ancestor_path on each item.
- 3998832722: reaction add/remove build their URL from
  http.publication_base(), so SUBSTACK_API_BASE reaches writes as it does reads.
- 3998832725: `feed read` builds limit/cursor with urllib.parse.urlencode, so a
  cursor containing &, #, + or % can no longer forge or truncate the query.
- 3998832730: run_webglass passes a finite timeout (120s, SUBSTACK_WEBGLASS_TIMEOUT;
  invalid -> CliError(1)) and maps TimeoutExpired to CliError(2) naming the variable.
- 3998832732: http passes a finite timeout to opener.open (30s,
  SUBSTACK_HTTP_TIMEOUT; invalid -> CliError(1)); timeouts retry on GET and
  raise a single CliError(2) on writes.
- 3998832734: UnicodeDecodeError/JSONDecodeError around response decoding on
  both the GET and write paths -> CliError(2) naming the URL, never retried.
- 3998832736: an argparse usage/unknown-verb answer from webglass now maps to
  CliError(2) saying webglass-cli has no authenticated request verb yet, citing
  agentculture/webglass-cli#17. The verbs stay registered.
- 3998832739: --body-json must decode to {"type": "doc", "content": [...]};
  anything else is CliError(1) with the ProseMirror remediation.
- 3998832738 (--send): behaviour unchanged by design; the help text and
  docstring now say the send:true path is unverified against the live API.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
…8513, S3358, S5799, S9073, S9100)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
…a composite assert (S9073)

HTTPError derives from URLError, so catching both is redundant; the
behaviour is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
… HTTP branch (S3776), merge a literal (S5799)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH5jAgaWUmCiXBJ3nKWdUV
@sonarqubecloud

Copy link
Copy Markdown

@OriNachum
OriNachum merged commit 38747f6 into main Sep 13, 2026
9 checks passed
@OriNachum
OriNachum deleted the feat/substack-surface branch September 13, 2026 06:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant