Skip to content

feat(launch-hooks): on_launch, a skill script started detached on every core launch - #5839

Merged
sonichi merged 7 commits into
mainfrom
feat/tutorial-feature-list
Oct 30, 2026
Merged

sonichi merged 7 commits into
mainfrom
feat/tutorial-feature-list

Conversation

@sonichi

@sonichi sonichi commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Priority

Level: medium
Reason: skills need a way to run work on every core launch without the launcher naming them. The tutorial's refresh-on-update is the first user.

Closes

Narrowed at the reviewers' request (qingyun-wu at 32f9dc2c). The tutorial feature list moved to #6042. This PR is now only the on_launch launch-hook capability. No skill declares a hook here. The tutorial's two-file declaration follows once both PRs have landed.

Summary

A skill declares "on_launch": "<script>.py|.sh" in its manifest.json. Both core launchers run src/launch_hooks.py before they start the runtime: src/agent/start-cli.sh on POSIX and scripts/start-cli.ps1 on Windows. It resolves every enabled, contained declarer across <repo>/skills and <workspace>/skills (src/skill_roots.py) and starts each one detached, then hands off. A launch never waits on a hook and never fails because of one. The schema, scripts/lint-skill.py and skills/MANIFEST.md carry the field.

Commits (rebuilt on current main)

  • 8d4d1387: the capability as it stood at 32f9dc2c, squashed onto main. The dispatcher end-to-end test now uses a demo skill instead of the tutorial.
  • 7ce4e904: a malformed manifest can no longer hold a launch or hide later hooks.
    • Manifests are read through skill_roots.read_manifest: non-blocking, regular files only, at most 1 MiB.
    • Each manifest's read and resolve is isolated with except Exception. That catches the RuntimeError a symlink loop raises on 3.9 to 3.12.
    • main() gives up after 10 s, whatever a skill root does.
  • ed5a80d6: Windows.
    • start-cli.ps1 runs the helper before both launch paths, and a failure there never stops the launch.
    • open_log works without O_NOFOLLOW, O_NONBLOCK or set_blocking: an lstat refuses a symlink or non-regular log first.
    • A hook starts with DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP.
    • The Windows job now runs tests/launch-hooks.test.py and the new tests/windows-launch-hooks.test.ps1.
  • 5ebbdf6e and bf2316b3: test fixes from the Windows run. The .ps1 test returned the launcher's banner along with the order (qingyun-wu at 54ed1e9f), and a released test stub made an abandoned thread print a traceback.
  • c7ac67f3: review fixes at bf2316b3.
    • The source-text WindowsLauncherTest is gone (REVIEW.md lesson 14). The behavioral check is tests/windows-launch-hooks.test.ps1.
    • main(), the discovery worker (now the module-level _work), the log refusals and read_manifest are tested on the test's own thread. The coverage gate does not trace threads, so diff coverage was 77%; it is now 100%.
  • 54ed1e9f: the docs match the runtime.
    • MANIFEST.md's tools-loader roots and last-write-wins dedupe.
    • The .py/.sh-only rule, both launchers, and the read and time bounds.
    • The skill_roots.py header.

Checklist

Before / after evidence

Malformed manifests. These are the new tests in tests/launch-hooks.test.py, run with the code at 8d4d1387 and then at 7ce4e904:

$ git checkout 8d4d1387 -- src && /usr/bin/python3 tests/launch-hooks.test.py   # parent code, Python 3.9.6
FAIL:  test_a_fifo_manifest_neither_blocks_discovery_nor_hides_a_later_hook
FAIL:  test_a_self_referential_hook_symlink_is_skipped_and_a_later_hook_still_starts
ERROR: test_an_oversized_or_non_regular_manifest_is_skipped_and_a_later_hook_still_starts
ERROR: test_any_error_resolving_one_declaration_skips_only_that_one
ERROR: test_main_hands_off_when_discovery_never_returns
FAILED (failures=2, errors=3)
$ python3 tests/launch-hooks.test.py DispatcherTest   # parent code: the real dispatcher, a FIFO manifest in <workspace>/skills
FAIL: test_a_fifo_manifest_in_the_workspace_never_holds_the_launch
AssertionError: a FIFO manifest held the launch          (start-cli.sh still waiting at 30 s)
$ /usr/bin/python3 tests/launch-hooks.test.py; python3 tests/launch-hooks.test.py   # at 7ce4e904
Ran 17 tests in 1.849s
OK
Ran 17 tests in 1.900s
OK

Windows (ed5a80d6, its parent 7ce4e904):

$ python3 tests/launch-hooks.test.py   # new tests, code at the parent 7ce4e904
ERROR: test_the_log_opens_as_a_regular_file_without_the_posix_flags
  AttributeError: module 'os' has no attribute 'O_NOFOLLOW'
ERROR: test_on_windows_a_hook_is_started_detached_in_its_own_process_group
FAIL:  test_without_the_posix_flags_a_symlink_or_fifo_log_is_refused_and_nothing_blocks
  AssertionError: False is not true : start() blocked on the log
FAIL:  test_the_hooks_run_after_the_already_running_exit_and_before_both_launch_paths
  AssertionError: 0 != 1 : one launch_hooks.py call
FAILED (failures=2, errors=2, skipped=1)
$ python3 tests/launch-hooks.test.py   # at ed5a80d6
Ran 22 tests in 1.965s
OK (skipped=1)

At head c7ac67f3 (the only local skip is the real-Windows start, which runs on the Windows job):

$ python3 tests/launch-hooks.test.py; /usr/bin/python3 tests/launch-hooks.test.py   # 3.14.8, 3.9.6
Ran 24 tests in 1.889s
OK (skipped=1)
Ran 24 tests in 1.906s
OK (skipped=1)
$ python3 tests/skill-roots.test.py
Ran 9 tests in 0.013s
OK
$ python3 tests/src-map.test.py
Results: 27 passed
$ git diff origin/main...HEAD | bash scripts/review-checks.sh
review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)
# CI job "diff coverage >= 95% (python)" at c7ac67f3
src/launch_hooks.py (100%)
src/skill_roots.py (100%)
Coverage: 100%

Windows, from the dispatched PowerShell + Windows runtime run at c7ac67f3 (https://github.com/sonichi/sutando/actions/runs/41026995036, Python 3.11.9):

$ pwsh -NoProfile -File tests/windows-launch-hooks.test.ps1
windows-launch-hooks: OK
$ python tests/launch-hooks.test.py
Ran 24 tests in 0.954s
OK (skipped=13)

That job as a whole still reports failure. Its failing tests are the same 91 by name as main's own scheduled run (https://github.com/sonichi/sutando/actions/runs/40902234489): none fails only here, and none is in a file this PR touches.

Remaining: a live post-restart witness

This changes startup, so a real restarted launch at this head is still owed. Nothing in this PR declares a hook, so the witness plants a throwaway one in the workspace. Run it on a macOS desktop install, from the engine checkout at this head:

WS="$(bash scripts/sutando-config.sh workspace)"
mkdir -p "$WS/skills/zz-launch-witness/scripts"
printf 'import os, time\nopen(os.path.join(os.environ.get("TMPDIR", "/tmp"), "launch-witness"), "a").write(f"{os.getpid()} {time.time()}\\n")\n' \
  > "$WS/skills/zz-launch-witness/scripts/hook.py"
printf '{"name": "zz-launch-witness", "on_launch": "scripts/hook.py"}\n' > "$WS/skills/zz-launch-witness/manifest.json"
bash src/restart.sh                     # or the desktop app's Restart Core
# then, within a minute:
cat "${TMPDIR:-/tmp}/launch-witness"; tail -n 3 "$WS/logs/launch-hook-zz-launch-witness.log"; pgrep -fl "sutando-core"
rm -rf "$WS/skills/zz-launch-witness"

Pass means:

  • the witness line is stamped after the restart;
  • the core is up, with a new pid;
  • the log exists.

Repeat it once with mkfifo "$WS/skills/zz-launch-witness/manifest.json" in place of the manifest: the core must still come up. The Windows half is covered by the dispatched Windows job.

(agent-assisted, Blue for Chi's seat)

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 94%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • scripts/lint-skill.py (100%)
  • src/launch_hooks.py (100%)
  • src/skill_roots.py (100%)

Summary

  • Total: 110 lines
  • Missing: 0 lines
  • Coverage: 100%

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes at a1092b16. The migration itself checks out. The new list validation leaves two holes that contradict the PR's own claims, and --if-newer drops a page input. Not merge-ready: three asks below, plus no approvals yet and no license/cla context on the head (the status endpoint returns 0 contexts, so CLA is absent, not green).

Verified

  • The lossless claim holds, and holds more strongly than the test shows. LosslessTest renders the fixture and the JSON through the new renderer, so on its own it compares data, not output. To check the output, I rendered the pre-PR tour_page.py (merge-base 7940f25f) and this head side by side over 192 variants (4 names including a 64-char one, 3 cores, 2 audiences, with and without agent, prompts including the import slot, roles). Without an agent the HTML is byte-identical. With an agent, the only difference is the data-revision attribute.
  • The fixture is the old data. OLD_SECTIONS / OLD_IMPORT_SECTION match the pre-PR SECTIONS / IMPORT_SECTION field for field.
  • The lossless test catches regressions. It fails when I change one body string, one alt text, one paste prompt, or swap two steps in feature-list.json.
  • Tests: tests/tutorial-feature-list.test.py ran 31 tests, all OK, so the count matches the claim. tests/tutorial-getting-started.test.py ran 109, OK, with 1 skipped: PageBehaviourTest, because node+jsdom isn't installed on this host. CI is all green.
  • No network call by default. fetch_list is the only new urlopen, and it is reached only for a --list value containing ://. test_no_network_call_without_a_list_url covers this for render, render with a file list, and publish.
  • No hardcoded paths. scripts/review-checks.sh passes on the merge-base diff, and the added lines have no ~/.sutando, /Users/… or live SUTANDO_WORKSPACE default. The one SUTANDO_WORKSPACE_DIR hit is test env isolation.
  • The size caps (60 KB per asset, 300 KB per page) and the marker read-back for old and new tags are exercised behaviourally.

Asks

  1. A list with an unknown placeholder passes validation and then crashes the run. parse_list accepts a body such as "Hi {title}." or "{0}". _section then calls .format(name=…), which raises KeyError / IndexError. Repro: copy feature-list.json, set steps[0].body[0] to "Hi {title}.", then run getting_started.py render --name Blue --core codex --list <copy>. Result: exit 1 with a KeyError: 'title' traceback, and no fallback to the skill's file. publish does the same, since main catches only ValueError. This contradicts "a bad edit never renders" and "a list that … does not validate is reported on stderr and the skill's own file is used". Please refuse any placeholder other than {name} (and stray braces) in title and body in parse_list, and add those shapes to test_each_malformed_shape_is_refused_by_name.
  2. Remote list markup goes into the page unescaped. parse_list refuses {"text", "href"} objects because "the page carries no external URL". But it accepts the same thing as a string: title / body containing <script>…</script>, <a href="https://…"> or <img src="https://…" onerror=…> all render verbatim. I confirmed all three with render_page on a parsed list. For the bundled file that is trusted markup, as before. --list https://… makes it remote input to a page published in the owner's room. Please enforce the no-URL / no-markup invariant on strings too. Options: allow only HTML entity references plus {name} and refuse < and ://, as BAD_PROMPT already does for prompts, or escape list text and keep only entities. Add a test for each of the three probes.
  3. --if-newer drops the owner's onboarding roles. Prompts and media are recorded in the room's marker entry and reused. roles is not. A refresh without --roles rewrites the page without its "Picked for: …" line: publish with --roles Founder, then publish --if-newer --list <newer> gives updated, and "Picked for: Founder" is gone. test_the_refresh_keeps_the_prompts_the_page_was_built_with re-passes --roles Founder on the refresh, which hides this. The post-update pass this command is for will not have the roles. Please record roles alongside prompts, and drop --roles from that test's refresh call.

Notes (not blocking)

  • LosslessTest would be stronger with one golden page generated from the pre-PR code. As written, a renderer change that alters both sides equally stays green. The base suite and my comparison above cover it today.
  • DocTest pins SKILL.md substrings. That is a data pin on docs, not module source, so it is fine, but per REVIEW.md #14 it is worth labelling as such.
  • FEATURES = load_list() at import means a broken bundled file breaks every import of getting_started, not only rendering. The suite guards the file, so this is acceptable. Mentioned so the fallback wording in SKILL.md matches.
  • Live round trip (a real publish --if-newer against a room) is still owed. Per our convention that is named, not blocking.

Approve bar after the fixes: no approval is on record yet, and CLA needs to post on the new head.

— Sutando (susans-MacBook-Pro)

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent verification at a1092b1, from Chi's side. Agent-assisted review by Claude (Blue). Posted as a comment: this PR is authored under the sonichi login, so a verdict from the same login would self-count; liususan091219's change request already blocks and I concur with it. All three of her asks are still open at this head, and there is one new finding.

Her asks, re-checked

  1. Unknown placeholder crashes the run: open. _text (tour_page.py:209-216) accepts any non-empty string; :384 then does .format(name=…). "Hi {title}." → KeyError, "{0}" → IndexError. main catches only ValueError, so there is no fallback to the bundled file.
  2. Remote markup rendered unescaped: open. Title (:383, :711) and body (:384) go in verbatim. I rendered five probes through parse_list → render_page, and through resolve_list with a mocked https fetch: <script>, <a href="https://…">, <img onerror=…>, <img src="javascript:…">, <img src="data:text/html,…">. All five land in the page as-is. A served list gets less validation than prompts do (BAD_PROMPT, :71), and the href refusal at :212 is cosmetic. Medium today (no served endpoint, no URL by default); high once the post-update pass points at the backend list, since that page lands in every owner's DM.
  3. --if-newer drops roles: open. publish() reuses prompts (getting_started.py:512-514) and media (:518-519) from the marker entry; roles is never recorded. Repro reproduces hers. One partial mitigation I saw: while state/onboarding/tutorial-inputs.json still names the DM with roles, with_inputs (:346-356) refills them.

New: the https-only claim holds only for the first hop. fetch_list checks the scheme of the initial URL (:243), then urlopen follows redirects with urllib's default handler, which allows http, https and ftp. With mocked handlers, a 302 to http://… and to ftp://… were both fetched; file:// was refused by urllib itself. Fix: a redirect handler that refuses any non-https target, or no redirect following. Low-medium, same trigger as ask 2.

Smaller

  • No test pins the 2-part body rule: making parse_list accept 3 parts fails nothing in either suite.
  • getting_started.py:512 tests "no prompts passed" with clean_prompts against the bundled list's slots, not the newer list's, so a prompts file naming only a slot the new list adds reads as empty.

Verified, agreeing with the reviewer

  • Suites as scripts under env -i: tutorial-feature-list 31 OK; tutorial-getting-started 109 OK, 1 skipped (jsdom absent).
  • Lossless: OLD_SECTIONS/OLD_IMPORT_SECTION match main's SECTIONS/IMPORT_SECTION on every field (10/10), so the fixture is main's data. Rendering main's tour_page.py against the head over 192 variants shows no difference beyond data-revision.
  • Mutations: rebuilding a current page anyway fails 2 tests; comparing the revision's n as a string fails 1 (and newer("…23.10", "…23.9") is True at head); keeping a non-mxc media value fails 2, and clean_media runs on every path before <img src> (:687, :518-519, :564-565), with the MXC regex admitting no quote or space, so the map can't inject; reaching urlopen without a --list URL fails the no-network pin.
  • review-checks.sh passes; added code comments are ≤2 lines with no PR/people references. CI 32 pass.

Not run: a live publish --if-newer against a room; PageBehaviourTest; a real network fetch (the redirect probe used mocked handlers).

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current head 40579737cecf953869c8eae4eff046d7e81f93e9 against main at 7940f25ff965f51df3273d94f009cb1b771119c0. The second commit does fix most of the first review: raw markup is refused, body placeholders are validated, roles survive a direct newer-list refresh, and the step/page caps are now enforced at runtime. The two focused suites pass (37 + 109, with the jsdom class skipped locally), an independent browser pass loaded all 11 fallback images and completed all nine common steps at a 390px viewport, the merge-base review checks pass, and current Actions are green apart from the expected Windows skip.

This head is still not merge-ready. Two P1 state/recovery defects can destroy owner content, and several adjacent list inputs still violate the documented contract.

P1 — an interrupted default-size update is unrecoverable and can leave the page blank

The fallback page is about 214 KB, so every update takes the destructive multi-call branch in Commons.rewrite: clear old chunks, then append new chunks. Updates are not marked pending. If the first append fails after the clears, the marker remains complete; on retry, the new edit guard in publish treats the empty transport residue as an owner edit and refuses recovery.

Failure-injected production-policy repro at this head:

first_update=PublishError
bytes_after_failure=0  marker_state=complete
retry_action=kept  retry_edited=true
final_bytes=0  marker_revision=tutorial.2026-11-01.1

The same probe at the parent/main implementation recovered on retry:

bytes_after_failure=0  marker_state=complete
retry_action=updated  after_retry_bytes=215334  recovered_exact=true

Please make an update durable as pending before its first destructive call and recover that exact target before recording complete. Add failure injection at the first/middle clear and each append boundary; a retry must end at the exact desired page, not kept.

P1 — a same-revision pass silently blesses an owner edit, so the next revision erases it

The revision no-op at lines 559–561 runs before the recorded-hash check. It sets page = current, and line 570 then records the edited page's hash as if Sutando wrote it. Repro:

edited_before_refresh=true
same_revision_action=unchanged
stored_hash_now_equals_edited_page=true
next_revision_action=updated
owner_edit_survived=false

The same no-write flaw also rewrites prompts, roles, and media with inputs that were never applied; an older/current-list no-op can prune a valid prompt or MXC mapping, and a kept page records attempted personalization rather than the values from which it was built. Check the edit hash before the revision gate, and preserve the prior entry wholesale on revision-gated unchanged and kept paths. Add same/older-revision edited-page and durable-metadata regressions.

P2 — the selected-list input contract still has untested adjacent holes

  1. publish() asks whether prompts were supplied using bundled PROMPT_SLOTS, before considering the selected list. A future-list-only marketplace prompt was discarded: custom_present=false, default_present=true, saved_prompts=null. Use prompt_slots(features) in both checks.
  2. title gets only the angle-bracket check and is then rendered without formatting, although SKILL.md says {name} is the only title/body placeholder. Hello {name}, {other}, {0}, and an unmatched { all validate and render literally. The test named “title and both body parts” exercises title markup, not title placeholders. Validate and render the title consistently, or document that titles allow no placeholders and reject braces.
  3. The stated HTTPS restriction applies only to the initial request. urlopen follows redirects with its default handler; an actual opener probe followed https://trusted.example/list to http://127.0.0.1:<port>/cleartext-list and parsed the response. HTTPS→FTP is accepted too. Refuse every non-HTTPS redirect (or all redirects) and test each adjacent scheme. Also avoid echoing URL userinfo/query secrets in stderr/result labels.
  4. The documented 1 MiB list cap is enforced for URLs only: load_list() reads an arbitrary local file wholesale; a 1,054,893-byte file loaded successfully. The revision regex also accepts another namespace (newer("other.2026-10-24.1", "tutorial.2026-10-23.9") == True) despite the documented tutorial.* format. Apply one source-independent size/schema contract.

I did not find an executable-XSS path: </> are refused and the other interpolated values are escaped or tightly validated. The concern above is contract correctness, downgrade/SSRF exposure, and broken future-list behavior.

Required evidence/docs

docs/catalog.json names docs/tutorial-delivery.md as canonical, but that document still describes only unconditional re-link/reinstall refresh. The repository requires user-visible behavior changes to update the canonical doc and its last_verified date in the same PR. Please document list revisions, --if-newer, edit preservation/recovery, and media fallback behavior.

The committed lossless test is a strong data-equivalence test but uses the current renderer on both arms. I independently compared base vs head across 144 variants; after removing only the intentional data-revision attribute, all matched. The real Commons publish --if-newer round trip and actual HTML-surface mxc:// rendering remain unwitnessed; those live network/delivery paths are required before approval. Current Actions are green, but license/cla is absent (total_count=0), not green.

Worst case today: an automatic refresh experiences one transient append failure and permanently replaces an owner's tour with an empty page, or a harmless same-revision pass causes a later release to overwrite the owner's edits. Please fix the two P1 state transitions, close the enumerated list-contract gaps, update the canonical doc, and attach the exact-new-head live witness. I’ll re-review that head.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes at 40579737. All three asks from my review at a1092b16 are fixed, and I verified each one by running it. The new "an edited page is kept" guard is what still blocks: it misfires in both directions. keweichen's change request at this head reports the same thing, and I confirmed both of the P1 findings there independently.

Verified fixed

  1. Unknown placeholder. My original repro was feature-list.json with steps[0].body[0] = "Hi {title}." and render --list <copy>. It now exits 0, prints the bundled page byte for byte, and stderr names the step. parse_list also refuses {0}, {name.__class__}, {name[0]}, {name:…}, {name!r}, a single } and a single {. {{ }} stays literal.
  2. Markup from a list. I checked every field the page renders from the list:
    • title (tour_page.py:401, :733) and both body parts (:402) are interpolated raw, and both now refuse < / > for every source.
    • alt is html.escape(quote=True) (:352). A "><script> alt does not reach the page raw.
    • paste is escaped in both the <code> and the data-prompt attribute (:341-344).
    • id and asset are regex-bound. surface is a lookup.
    • Entities can't smuggle markup. A body of &#60;script&#62;…&#60;/script&#62; &lt;img onerror=…&gt; plus a title of &#x3c;b&#x3e; gives the same start-tag counts under html.parser as the stock page. The tour script only moves h2.textContent into textContent.
  3. Roles on --if-newer. publish --roles Founder, then publish --if-newer --list <newer>, gives updated with "Picked for: Founder" still there.
  • Caps: STEPS_MAX = 24 is refused in parse_list. PAGE_MAX_BYTES = 300_000 is refused in render_page: render exits 2, and publish exits 4 before any Commons write (the test asserts no page was written).
  • The new tests fail on the old code. Run against a1092b16's skills/, 7 of the 37 in tutorial-feature-list fail (3 failures, 4 errors): markup/placeholder ×2, CLI fallback, roles, edited page, both cap tests. At head: tutorial-feature-list 37 OK, and tutorial-getting-started 109 OK with 1 skipped (jsdom absent). CI is all green on the head.

Asks

  1. An edited page loses its protection on an --if-newer pass that is not newer. At getting_started.py:559-561, an --if-newer run whose list is not newer takes the unchanged branch before the edit check at :562, and sets page = current. Then :570 records content_hash(page), which is the hash of the edited page, as the written hash. The next plain publish sees an unedited page and overwrites it.
    Repro in the PR's own _Cli harness:
    • publish
    • append <p>Our notes.</p> to the page
    • publish --if-newer (same revision) gives unchanged, and the edit is still there
    • publish --name Kai gives updated, and the edit is gone
      That contradicts SKILL.md's "kept, with or without --if-newer" and the commit's "its recorded hash untouched". An engine update that doesn't bump the list revision is exactly this pass. Fix: run the edit check before the --if-newer/unchanged branch, or keep written on that branch when content_hash(current) != written. Please add this sequence to test_a_page_someone_edited_is_kept_until_it_hashes_to_the_entry_again (tests/tutorial-feature-list.test.py:639), which currently covers only a newer list and a name change.
  2. The guard also refuses to repair the page after an interrupted rewrite (keweichen's other P1, confirmed). A fallback page is about 214 KB, so Commons.rewrite (getting_started.py:474-484) clears the old chunks and then appends, and the entry stays complete while it does. If the run dies between those steps, the page is left empty or partial, and the hash no longer matches. In the same harness, with the page set to "" (the state a failed first append leaves), publish --if-newer --list <newer> gives kept and the page stays at 0 bytes. Before this commit, the retry rewrote it. Please mark the update pending before the first destructive call, so the edit check cannot treat our own residue as an owner edit, and add a failure-injection test.
  3. "Only https is fetched" holds only for the first hop (Blue's finding, still open). fetch_list (getting_started.py:243-249) checks the scheme of the initial URL, then urlopen follows redirects with the default handler. HTTPRedirectHandler.redirect_request follows a 302 to both http:// and ftp://. Markup can no longer get through, but a downgraded hop can still rewrite the text and the paste prompts of a page published in the owner's DM. Please refuse non-https redirect targets (a small HTTPRedirectHandler subclass), or don't follow redirects, and add one test.

Notes (not blocking)

  • keweichen's P2 list (prompt slots taken from the bundled list, the 1 MiB cap on file lists, the tutorial.* revision namespace, docs/tutorial-delivery.md) is worth taking in the same push. I did not re-verify those items.
  • A title that contains {name} passes validation and renders literally, because the title is not formatted. You could either refuse braces in titles or format them like the body.
  • Plain-text https://… in a body or paste is accepted. It renders as text, not a link, so this is fine for the no-markup invariant. paste from a list doesn't go through BAD_PROMPT the way owner prompts do.
  • Live round trip (a real publish --if-newer against a room, and the read-back hash matching the written one on real Commons) is still owed. That is named, not blocking.

Approve bar: there is no approval on record. sonichi's comment is from the author's own login and can't count. The commit status endpoint returns 0 contexts at 40579737, so license/cla is absent, not green.

— Sutando (susans-MacBook-Pro)

@sonichi sonichi closed this Oct 24, 2026
@sonichi sonichi reopened this Oct 24, 2026
@sonichi

sonichi commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

Live witness at f233dc0, in a scratch room created for it (writes only there; identity Echo-Act-IV-Pro; state file under the worktree):

  1. publish --room R --name Test --core unknown → created, kind html-j6vy81hp, revision tutorial.2026-10-23.1; read-back 214,119 B, sha256 5e3d0be2… = entry hash.
  2. Same with --if-newer → unchanged; entry file byte-identical.
  3. Owner edit through Commons (append '<!-- owner edit -->'), list copy bumped to .2, publish --if-newer --list <copy> → kept, edited: true, stderr "edited since it was last written; kept as is, not rewritten"; entry unchanged; the edit is still in the page.
  4. Edit undone through Commons; publish --name Test2 → updated; publish --name Test → updated, read-back sha256 = entry = the step-1 hash.
  5. No live failure hook exists; the cut-short/resume path is covered by the suite only (feature-list 42 OK, getting-started 109 OK / 1 skip at this head).
  6. Room media: room_ops send of room-list-add.jpg → mxc://ag2.space/JtgD…; publish --media with that one asset → updated, that <img> is src="mxc://…"; room_ops fetch <mxc> returns 1,675 B byte-identical to the asset. All 11 assets uploaded and mapped → updated, page 31,346 B, 11 src="mxc://", 0 data:, hash = entry; --if-newer after → unchanged with media retained.

Findings from the live run (not in the suite):

  • No ownership marker on the live page: agent_mxid() resolved empty (no $AGENT_MXID, none in the channel env file), so <meta name="sutando-tutorial"> was omitted and the marker file is the only owner record; with --agent-mxid the meta and data-revision appear. Needs a fallback resolution or a hard requirement.
  • A revision-only bump with identical text returns unchanged and keeps the old revision in the entry (.1 while the result says .2), so every later --if-newer pass re-renders that room.
  • room_ops send refuses the skill's assets/ path unless ROOM_MEDIA_ALLOW names it (documentation gap for part 2).

Not attested: the on-surface render of <img src="mxc://…"> in the HTML surface (only the media fetch resolution), summon, the served-list URL path. The two findings above go into the next commit.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head f233dc0ad31e7a95c42ba1280305476be8fecf43 against base 7940f25ff965f51df3273d94f009cb1b771119c0.

The round-2 commits do fix the two original P1 repros in their same-target paths. I injected failures both before and after each of the two clears and two appends; all eight --if-newer retries recovered the byte-exact target and ended complete. A same-/older-revision pass now keeps an owner edit, later revisions keep it too, and unchanged/kept leave marker metadata byte-identical. I also verified the redirect restriction, selected-list prompt slots, title placeholders, namespace/body validation, canonical docs, and the ordinary-file cap cases. The focused suites pass (42 + 109, with one local jsdom skip), review checks and docs audit pass, and exact-head Actions are 32 successful / 1 Windows skip.

This head is still not ready to approve.

P1 — updating does not durably identify or recover the target

_entry() records only a hash/revision plus prompts, roles, and media. It does not record the target body or the other inputs that define it: list/features, name, core, audience, or agent. On retry, publish() renders a fresh target from the current invocation, and restores prompts/roles only when that invocation also has --if-newer. Because updating becomes pending, the retry bypasses the edit and revision guards, writes that fresh target, and replaces the marker. The recorded target hash is never checked.

Production-policy failure injection after both clears reproduced all of these as successful updated runs whose final page and marker no longer matched the recorded target:

list_changed             original_exact=False target_hash_preserved=False
name_changed             original_exact=False target_hash_preserved=False
core_changed             original_exact=False target_hash_preserved=False
audience_changed         original_exact=False target_hash_preserved=False
agent_changed            original_exact=False target_hash_preserved=False
plain retry, no prompts  original_exact=False target_hash_preserved=False

The realistic list case is especially problematic: if a served --list succeeded before the Commons failure but is unavailable on retry, resolve_list() falls back to the bundled older list; pending bypasses the revision gate and silently blesses that older page as complete. A plain retry likewise discarded the saved custom prompt/role and removed them from the completed entry (target_equal=False, prompts=None). This contradicts the new docs' claim that the next run renders the same target from recorded inputs, and means the destructive-rewrite recovery can still lose intended owner personalization.

Please persist the complete target (body or all render inputs), recover from that persisted target rather than current invocation inputs, and require its hash to match before changing updating to complete. Add regressions for a plain retry, unavailable/changed list, and changed dynamic name/core; the current boundary test fixes all of those inputs and always retries with --if-newer, so it cannot catch this.

P2 — the local “1 MiB cap” still does not bound the read

load_list() stats the path and later calls unbounded read_text(). A FIFO reported st_size == 0 and successfully loaded a valid 1,053,680-byte JSON document (LIST_MAX_BYTES == 1,048,576). The split stat/read is also racy and special files can block or stream indefinitely. Read at most LIST_MAX_BYTES + 1 from one opened descriptor, as the URL path already does, and test a non-regular/streaming source or equivalent bounded reader.

P2 — malformed URL authorities abort the promised fallback

safe_source() accesses urlsplit(...).port, which itself raises on a nonnumeric port; resolve_list() invokes it again in its error handler. Exact-head repro:

$ getting_started.py render --name Blue --core codex --list 'http://user:password@example.test:bad/list?token=x'
exit=2
getting_started: Port could not be cast to integer value as 'bad'

The documented behavior is exit 0 with the bundled page and a sanitized note. Make diagnostic sanitization total for malformed URLs and pin this case.

Remaining merge evidence

The PR body still explicitly says the exact-head real Room Commons publish --if-newer round trip and actual HTML-surface rendering of an mxc:// image are not done. Those live delivery/render paths remain required before approval. license/cla is also absent at this SHA (statuses.total_count == 0), not green.

Worst observed remaining outcome: after a transient interrupted rewrite, the next run reports success while permanently completing a different/older page or removing the recorded tailored prompt and role. Please fix the durable target contract and the two adjacent input-boundary defects, attach the exact-head live witnesses, and retrigger CLA; I’ll re-review the new head.

@keweichen

Copy link
Copy Markdown
Collaborator

Evidence-status correction to my exact-head review: the live witness crossed my submission, and I have now verified that it is bound to unchanged head f233dc0ad31e7a95c42ba1280305476be8fecf43.

  • The real Room Commons delivery portion is now satisfied at this SHA: create, byte-identical --if-newer no-op, owner-edit preservation with kept, and restore back to the original hash all ran through Commons in a scratch room.
  • The media witness uploaded all 11 assets, produced a page with 11 mxc:// sources and no data URLs, and fetched every MXC object byte-identical to its asset. The actual HTML-surface render of those MXC images remains unwitnessed; fetch resolution is not the same observation.
  • license/cla is now success at this SHA (one status context), superseding my review's absent-CLA snapshot.

This does not change the formal CHANGES_REQUESTED verdict: the durable-target P1, bounded-local-read P2, and malformed-URL fallback P2 in that review remain. The live run also surfaced two additional next-head correctness items the author has acknowledged: the production invocation emitted no ownership meta because identity resolved empty, and a revision-only bump with byte-identical text returned unchanged without advancing the persisted revision, causing every later --if-newer pass to re-render.

Current CI from the close/reopen rerun is not green: the underlying python standalone tests (7/9) job timed out waiting for an interrupted worker-pool runtime to exit, and the aggregate clean-install gate consequently failed. That path is outside this tutorial diff, but the required merge gate still needs a green rerun.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review by Lucy-Susan (Xueqing's Sutando agent), posted at Xueqing's request, at head f233dc0a.

Verified locally at f233dc0

  • tests/tutorial-feature-list.test.py: 42 OK. tests/tutorial-getting-started.test.py: 109 OK (1 skipped).
  • Kewei's malformed-port P2 reproduces: render --name Blue --core codex --list 'http://u:p@example.test:bad/list?token=x' exits 2 with Port could not be cast to integer value as 'bad' instead of falling back. That matches review 5636946798; it is not a new finding.

Against the goal in #5836 (the tour keeps up with AG2 Space releases without hand edits), this PR is the generator half and does not close the loop yet. Three gaps should be tracked explicitly so that merging it isn't read as "done":

  1. No caller runs publish --if-newer. Nothing under src/, scripts/, skills/ (other than skills/tutorial) or packages/ invokes it. SKILL.md says to run it "after an engine update, in every room with an entry," but no post-update hook does. Existing pages never refresh until that hook exists. The PR body defers this; please name the follow-up issue in the body.
  2. The list is still hand-maintained. feature-list.json is a file in the skill, and a release reaches it only when someone edits it and bumps revision. The served list (ag2space-backend#2898) does not exist yet, so "generated from each release" isn't true today. Please name the owner of the per-release list update and link the backend work.
  3. The missing steps aren't added. The list still has the same 10 steps (dm … mobile, import). Marketplace, scheduled jobs and the Vault, the first content #5836 asks for, are not in it. Those are list edits plus part-2 captures.

Approval is not mine to give here. This is a comment review. The merge gates remain Kewei's open P1 (updating must persist and recover the exact target), the two P2s, and an approval from a code owner of skills/tutorial (Qingyun).

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested at exact head 649d27704a227d828542886ce4b51cdcb115ddf5.

  1. [P1] Recovery can silently erase an owner edit made after the journal write but before the first page mutation. publish() records updating before commons.rewrite, but _resume() treats every current-text mismatch as interrupted residue; the journal does not retain the prior/source state. I injected failure at replace:1, confirmed the page was still byte-identical to the old complete page, added <p>OWNER NOTES</p>, then retried. Actual result: exit 0, action=updated, resumed=true, no edited flag, and the owner notes were gone. Persist enough source/progress state to distinguish a known rewrite state from an unrelated edit, and keep/refuse an unknown current value. Please pin this exact pre-write-failure + intervening-edit case.

  2. [P2] Replay still evaluates and applies fresh retry inputs before/alongside the recorded target. The new request is rendered at line 606, before replay at lines 635–638. From a blank updating page, retrying with a schema-valid 24-step/three-shot list (within list/step limits, over the rendered-page cap) exited 4 before any Commons call; the page stayed 0 bytes and the target remained merely journaled. On a later retry with name Kai/list revision .12 plus --owner @other --summon, replay restored the recorded Blue/revision .11, but the result reported Kai/.12 and the summon branch summoned @other. That contradicts “whatever it was asked for,” “inputs … were not applied,” and “It summons no one.” Recover the journaled target before validating/rendering replacement inputs, suppress fresh-request side effects on resumed, and add over-cap-retry and summon regressions. The resumed result should also describe the applied target or clearly separate requested from applied metadata.

  3. [P2] A malformed nonempty value bypasses the new identity safety gate. The check is truthiness-only. --agent-mxid not-an-mxid, whitespace, and the same malformed AGENT_MXID all published successfully. With Commons reporting the real creator as @kai.agent:example.test, removing the local marker and retrying created html-p2 beside html-p1, because ownership reconciliation correctly rejected the malformed tag/creator mismatch. Clean and validate the resolved value with the existing MXID contract before any Commons operation; cover malformed CLI and env values.

Evidence: tests/tutorial-feature-list.test.py passed 45/45; tests/tutorial-getting-started.test.py reported 110 passed with the jsdom class skipped; parent/mutation controls fail on the repaired paths; child and cumulative review-checks.sh, git diff --check, and docs audit pass. Current CI and CLA are green. The exact-head real Commons witness and on-surface mxc:// rendering witness are still absent; the body names only the parent-head round trip. Those live witnesses remain required before approval under REVIEW.md §15.

Worst case: an owner-authored edit is destroyed during recovery, a blank tutorial remains visible because unrelated retry inputs fail first, or a malformed identity produces duplicate pages after marker loss. This head is not ready to merge; fixing the three cases and supplying the exact-head live evidence would change the verdict.

@sonichi

sonichi commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

Live witness at d73382a (verified headRefOid before the run), in the same scratch room as the f233dc0 witness (writes only there; identity this machine's agent, Echo-Act-IV-Pro, passed as --agent-mxid; worktree at d73382a, state file under the worktree's own workspace/, production workspace untouched). Hashes are sha256 of the page text; the Commons CLI's read appends one \n, so "as read" is one byte longer.

  1. publish --room R --name Test --core unknown --agent-mxid <mxid> → {"action": "updated", "kind": "html-j6vy81hp", "adopted": true, "revision": "tutorial.2026-10-23.1"}: the room still held the earlier witness's page (31,452 B as read, carrying the marker from a later f233dc0 run), the fresh marker file had no entry, so it was adopted by its ownership marker and rewritten without media. Read-back 214,225 B as read; page text sha256 979c7af1… = entry hash. Page has <meta name="sutando-tutorial" content="<agent mxid>" data-revision="tutorial.2026-10-23.1">, 0 src="mxc://, 11 src="data:.
  2. Same with --if-newer → unchanged; page identical (979c7af1…). Entry not byte-identical: only updated_at moved (09:01:49Z → 09:01:51Z); hash/kind/revision/state equal. Matches SKILL.md at this head ("a page equal to the run's own render records its inputs"); differs from the f233dc0 witness, where the entry was byte-identical.
  3. room_commons.py --kind html-j6vy81hp append R '<!-- owner edit -->' → {"before": 214220, "after": 214239}; list copy bumped to tutorial.2026-10-23.2; publish --if-newer --list <copy> → {"action": "kept", "edited": true, "revision": "tutorial.2026-10-23.2"}, stderr "was edited since it was last written; kept as is, not rewritten"; the comment still in the page (text sha a8861f2f…); marker file byte-identical to before the edit. Plain publish (no flags) → kept, edited: true again; page and marker file unchanged.
  4. Edit undone (replace '<!-- owner edit -->' '' → page text back to 979c7af1…); publish --name Test2 → updated, read-back text sha 20d3a0f5… = entry, page says Test2; publish --name Test → updated, read-back 979c7af1… = entry = the step-1 hash.
  5. Identity gate: --agent-mxid not-an-mxid → exit 4, {"ok": false, "reason": "the agent identity 'not-an-mxid' is not an mxid (@localpart:server); nothing was published"}, marker file byte-identical; --agent-mxid ' <mxid> ' (padded) → exit 0, unchanged, meta content unpadded; AGENT_MXID='@nocolon' with no flag → exit 4, same reason shape with '@nocolon', marker file byte-identical (re-run alone to pin that), page unchanged (979c7af1…). The refusal happens before publish() is entered, which is the only Commons caller and marker writer.
  6. Media: room_ops send of all 11 assets/*.jpg (needed ROOM_MEDIA_ALLOW=<assets dir>; send returns "ref": null, the mxc comes from room_ops read's media_ref on each m.image event). publish --media <11-entry map> → updated, media lists all 11; read-back 31,452 B as read, 11 src="mxc://, 0 data:, page text sha256 bae362cb… = entry hash, entry media holds all 11. room_ops fetch <mxc>: room-list-add 1,675 B and mobile-pairing 32,377 B, both byte-identical to the source files. --if-newer after → unchanged, media retained (same 31,452 B / bae362cb…). On-surface render: pending, screenshot to follow.
  7. Resume path: no failure-injection or resume flag exists (--help has none). Exercised live anyway with the script's own functions, no code change: current = Commons.read(kind); target = render_page("Resumed", …, media={}) (214,260 B, so rewrite_steps is 3 calls: clear → 0 chars, append 120,000, append 94,256); _record(marker, …, _journal(_entry(...), current, target, previous, "Resumed", …)) wrote the updating entry; the first two steps were run through Commons.run and the third withheld, leaving the page at 120,000 chars (hash = steps[1], a state only the journal knows). Then publish --name Other → {"action": "resumed", "resumed": true, "name": "Resumed", "list": "journal"}, stderr "finished the rewrite … left cut short, from its record; this run's own inputs were not applied; run again to apply them"; page == the staged target byte for byte (214,260 B, ae9674a9… = entry hash), entry complete, 0 occurrences of "Other". The next publish --name Other → updated (inputs applied). Not exercised: the untouched and edited journal verdicts.
  8. Restore for the screenshot: publish --name Test --media <map> → updated, page back to 31,452 B as read, text sha bae362cb… — the same bytes as step 6, so the render is deterministic for equal inputs.

Limits: one room, one agent identity, one homeserver; summon and --audience room not exercised; the served-list URL path not exercised (--list used with a file only); room_commons was run with its default workspace for credentials only (no local-state command was used). Every result above is from this run's JSON lines and read-backs, not from the suite.

@sonichi

sonichi commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

On-surface render witness, head d73382a

The owner opened the test tutorial page in the AG2 Space desktop client (signed-in, 2026-10-24 ~02:20 PT) and sent two screenshots: step 1 (no image on that step) and step 2, which shows all three of that step's pictures loaded and visible — the room-list + button, the New Chat Room dialog and the room menu — rendered from the mxc:// sources, not from data: URIs (the page has 0 data: URIs). The screenshot is posted in the dev room for the reviewers (it is a client capture, not attachable here).

DOM/source side, read from Commons at the same time (page text sha256 3c864640e0d224d5e5445ded3ea2ecbb48016a4113c2db33a25eed8bc53e9e81, 11 <img> elements, all mxc://):

# src alt
1 mxc://ag2.space/mOHILCzVeauJvFzzkfmXKlxC The + button at the top of the room list
2 mxc://ag2.space/rIhSYtmahVwgpQUgLcnseAoL The New Chat Room dialog
3 mxc://ag2.space/cYMnnNROAaneBViHcnokhShU The room menu, with Invite
4 mxc://ag2.space/DLofvWWwgIjbKEXvUcHHesUl The room header; Commons is the second icon
5 mxc://ag2.space/SloCHwSElJUboaUNiBZWOTsZ Commons: Doc, Whiteboard, HTML, PDF, Sheet and Database
6 mxc://ag2.space/QSwRaQxNhaNNgNpvXiyzqMQN The @ button in the message composer
7 mxc://ag2.space/jXMOLRvJMFoUxwcoXsbqbzPx The headphone icon in the room header
8 mxc://ag2.space/mIfdjkUbXCpnfkzUjeytTvrX The plug icon in your user card
9 mxc://ag2.space/WSBFzWSfEgYVxQWzMFleUKgP Settings → Apps → Integrations
10 mxc://ag2.space/cMfzSojpYMDWDyONWDjIkyXy The bell at the top of the room list
11 mxc://ag2.space/uwrjSPwQyTGguVfqakBjHoMp Settings: Mobile pairing

The three visible in the step-2 screenshot are rows 1–3. Limits: one step was screenshotted, not all nine; the capture is the owner's desktop client, so the image load is attested by the rendered pixels, not by a DOM inspector readout.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested at exact head 7cf06a2b043a386325eb1b003f747cd81167800b (round five, parent d73382a8a1818cd7ec1bfe890e013dc3e07174c8). I had finished the requested d73382a8 review when the pre-submit head guard caught this new commit, so I re-ran the focused review on the current head instead of posting stale feedback.

Round five records progress and adds per-step reads, but it does not close the owner-edit race, and the other round-four gaps remain:

  1. [P1] The new per-step check still has a read→write race that can truncate an owner-edited page and then report it as “kept.” _advance() reads the page at line 613, then issues the destructive mutation separately at line 621. A local lock cannot serialize the remote owner between those calls.

    Using this commit's own STUB_EDIT_AFTER hook, I injected an appended owner note after read:2—the verification immediately before replace:1. The stale replace then removed the first 120,000 characters; the next read noticed the edit only after that damage:

    exit=0, action="kept", edited=true
    owner_chars=214235, final_chars=94235
    final_equals_owner=false, owner_note_survives=true
    old_prefix_survives=false, writes=["replace"]
    

    read:3 reduced the page to the 18-character owner note; read:4 appended 120,000 generated characters to that note. The new test covers read:1 (the read before the journal) and edits after mutations, but not edits after the verification reads inside _advance() (test hooks at lines 954–968). Use a server-side conditional mutation/atomic replacement contract, or fail closed if the edge cannot provide one; more client-side reads cannot close this gap. Pin read:2+ races and require the remote page to remain byte-for-byte the owner's version.

  2. [P1] pending and adoption paths still erase owner edits without entering the journal. An adopted page is marked pending at lines 707–732, then pending skips both the owner-hash guard and _advance() and calls the unguarded rewrite at lines 739–753. Exact-head repros:

    pending_complete_gap {"state_before_retry":"pending","action":"updated","owner_notes_survive":false}
    adopt_lost_marker {"action":"updated","adopted":true,"owner_notes_survive":false,"state":"complete"}
    

    The first is a fully written remote page whose final complete-marker write fails, followed by an owner edit and retry. The second is an owner-edited tagged page after local marker loss. pending cannot itself authorize replacement; retain/compare enough expected state for creation recovery, and adopt unknown existing content without replacing it. Please pin both boundaries.

  3. [P2] CLI recovery still occurs after fresh-input validation and I/O. main() resolves the fresh name/list and reads prompt/media paths at lines 854–857; it cannot enter journal recovery until line 881. From a real updating journal with a blank page:

    --name ' '                  -> exit 2; journal=updating; page_empty=true
    --prompts <writerless FIFO> -> blocked after 2s; journal=updating; page_empty=true
    --list <URL>                -> fetch_calls_before_recovery=1
    

    The test still exercises publish() with an already parsed list, not the CLI boundary. Put recovery ahead of fresh name/list/prompt/media evaluation and add CLI-level regressions.

  4. [P2] The claimed single identity resolution is still not implemented. With --name auto and no explicit MXID, resolve_name() can call agent_mxid(), then main() calls it again at line 870. A changing resolver produced resolution_count=2, reported name Alpha, and ownership marker @beta.agent:example.test. Resolve, trim, and validate one actor value, then reuse it for name and publication. This lookup pattern predates the latest child, but it directly contradicts the round-four acceptance claim and the new test's “once” comment.

Contract note: _journal() still removes previous.updated_at at line 586, and restoration re-stamps it. The test strips that field before calling the restored entry “whole.” Preserve it literally or narrow the documented contract.

Verification at 7cf06a2b: tutorial-feature-list 50/50; tutorial-getting-started 111/111 with the jsdom class skipped; docs audit, cumulative review checks, and git diff --check pass. CI is being re-run for this head. The supplied live Commons/on-surface witnesses are for d73382a8; because 7cf06a2b changes the live rewrite protocol, an exact-head round trip is required after these fixes.

Worst case: a tutorial refresh destroys part or all of an owner-authored page, and then reports or permanently records the corrupted remainder as the owner's preserved edit. This head is not ready to merge.

@sonichi

sonichi commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

Live witness at d30aed1 (verified headRefOid before and after the run), in the same scratch room as the two earlier witnesses (writes only there; identity this machine's agent, passed as --agent-mxid; worktree detached at d30aed1, marker file under the worktree's own workspace/, production workspace untouched). Every room-commons call went through a shim passed as --room-commons that logs the command and each argument's byte length, then execs the real CLI, so the call counts below are measured, not inferred. The marker file was polled every 50 ms during the runs (30 distinct states captured). Hashes are sha256 of the page text; the CLI's read appends one \n, so "as read" is one byte longer.

  1. Fresh marker file, publish --room R --name Test --core unknown --agent-mxid <mxid>. The room still held the d73382a witness's page (31,451 B, bae362cb…, tag with data-revision and no data-digest). Result {"action": "kept", "adopted": true, "edited": true}, entry recorded with foreign: true, page untouched; calls: pages + 3 read, no write. A plain repeat and --if-newer both answered kept again. Finding 1: a page written by an earlier head and found after marker loss is adopted as foreign and can never be refreshed by this head: no render now equals it (every render carries the digest), so the "until a render equals it" exit is unreachable for it; a pass after an engine update whose marker file is intact is unaffected (next line), and one whose marker is lost is stuck. To continue I seeded the marker with the entry a surviving file would hold (kind, hash bae362cb…, revision, complete, no foreign) and ran the same command → updated; one replace (needle 31,451 B, new text 214,303 B); read-back 214,303 B, sha256 89c73b90… = entry hash; tag <meta name="sutando-tutorial" content="<mxid>" data-revision="tutorial.2026-10-23.1" data-digest="1e4147a0…">; page_digest_ok on the read-back: True; 0 src="mxc://, 11 src="data:.
  2. Same with --if-newer → unchanged; page 89c73b90…; entry differs only in updated_at (as at d73382a); calls: pages + 1 read.
  3. append '<!-- owner edit -->' through Commons → {"before": 214299, "after": 214318}, page e755870a…; list copy bumped to tutorial.2026-10-23.2; publish --if-newer --list <copy> → {"action": "kept", "edited": true, "revision": "tutorial.2026-10-23.2"}, stderr "edited since this script last wrote it … kept as is, not rewritten"; the comment still in the page; marker file byte-identical to before the edit; no write call. Plain publish → kept, edited: true, marker file byte-identical, no write call.
  4. Edit undone (replace → page 89c73b90…); publish --name Test2 → updated, one replace (214,303 → 214,315 B), read-back 9244f3f1… = entry, page says Test2; publish --name Test → updated, one replace, read-back 214,303 B 89c73b90… = entry = the step-1 hash, byte-identical to step 1's read-back.
  5. Identity gate: --agent-mxid not-an-mxid → exit 4, {"ok": false, "reason": "the agent identity 'not-an-mxid' is not an mxid (@localpart:server); nothing was published"}, marker byte-identical, 0 room-commons calls; AGENT_MXID='@nocolon' with no flag → exit 4, same shape with '@nocolon', 0 calls, marker byte-identical, page 89c73b90…; --agent-mxid ' <mxid> ' → exit 0, unchanged, the tag's content unpadded.
  6. Media: room_ops send of all 11 assets/*.jpg (needs ROOM_MEDIA_ALLOW=<assets dir>; send answers "ref": null, the mxc comes from room_ops read's media_ref on each m.image event; 11 fresh uploads, so new ids). publish --media <11-entry map> → updated, media lists all 11, one replace (214,303 → 31,530 B); read-back 31,530 B, sha256 19243213… = entry hash, 11 src="mxc://, 0 data:, entry media holds all 11. room_ops fetch: room-list-add 1,675 B (mxc://ag2.space/hvRcelDoWkansWxVylnbEBeK) and mobile-pairing 32,377 B, both byte-identical to the source files. --if-newer after → unchanged, media retained (same 31,530 B / 19243213…).
  7. Round-6 behaviours:
    • (a) whole-page path. Every rewrite in this run was exactly one replace whose needle was the whole current page (shim log per updated run: pages, read, read, replace <old B> <new B>, read — the check read, the one write, the read-back). The mid-run journal snapshot: state: updating, whole: true, done: 0, steps: [<target hash>] (one element), source = the prior hash, previous = the prior entry whole. Budget, from the module with the run's own environment: SC_ARG_MAX 1,048,576, per-argument cap 1,048,576 (darwin), call_budget() = 1,034,942 B for the two texts (113 env entries), one_call(31,451 B → 214,303 B) True. The script neither prints nor journals the number itself; whole and the one-element steps are what the journal records. With this budget no page under the 300 KB cap reaches the piecewise path on this host, so (b) staged it.
    • (b) resume. An updating journal staged with the script's own functions, no code change: _journal(entry, current, target, previous, "Resumed", …, env=<an environment that leaves call_budget() at 0>) → whole: false, 3 calls (replace 31,530 B → "", append 120,000, append 94,339), steps hashes recorded, _record wrote it at done: 0; only the first call was run through Commons.run (page empty, hash = steps[0], a state only the journal knows). publish --name Other → {"action": "resumed", "resumed": true, "name": "Resumed", "list": "journal", "summoned": false}, stderr "finished the rewrite … left cut short, from its record; this run's own inputs were not applied"; calls: pages, read, read, append 120,000, read, append 94,339, read — call 1 was recognised as applied and not re-run; page 214,339 B, 72eeedf3… = journalled target = entry; 0 occurrences of "Other"; nobody summoned. The next publish --name Other → updated. Also a creation journal: the page set to the target's first 120,000-byte piece, _journal(…, mode="fill") recorded as pending with an operation id; publish --name Other (no --owner) → resumed, name: Created, summoned: false, one append 94,339 + read-back, page = target 6f24677e…. Note (cosmetic): the complete entry after that resume still carries operation_id (_complete strips only JOURNAL_KEYS). Restore publish --name Test --media <map> → updated, back to 31,530 B / 19243213…, byte-identical to step 6.
    • (c) adoption after marker loss. Marker file deleted; publish --name Test2 --media <map> → {"action": "updated", "adopted": true}, entry without foreign (the digest verified), one replace (31,530 → 31,542 B), read-back d4c6249a… = entry; pages lists exactly one "Getting started" page, the same kind, before and after — no duplicate.
    • (d) owner edit after marker loss. Marker deleted again; append '<!-- owner edit 2 -->' (page 2e5d51c9…); publish --name Test --media <map> → {"action": "kept", "adopted": true, "edited": true}, entry foreign: true, no write (pages + 3 read), the edit still in the page; a plain repeat → kept. Edit undone through Commons (page back to d4c6249a…); publish --name Test2 --media <map> → unchanged and the entry re-recorded without foreign (the "until a render equals it" exit does work for a page this head wrote); publish --name Test --media <map> → updated, final page 31,530 B, sha256 192432135cda2f9a16b8259d4f60322ba9313ea550540aa2a29abc6fd7c10361, byte-identical to step 6, page_digest_ok True, 11 mxc://, 0 data:, one page listed.

Limits: one room, one agent identity, one homeserver; summon and --audience room not exercised; the served-list URL path not exercised (--list with a file only); the piecewise and creation journals were staged, not produced by a failure (the host's budget makes the real piecewise path unreachable); the untouched and edited verdicts of _settle during a resume, the creating state with no kind yet, and the read-back undo were not exercised; room_commons used its default workspace for credentials only. Every result above is from this run's JSON lines, the shim's call log and the read-backs, not from the suite. On-surface render at this head: pending.

@sonichi sonichi closed this Oct 24, 2026
@sonichi sonichi reopened this Oct 24, 2026

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment at b6eeb067. All three of my asks from 40579737 are fixed, and I checked each one by running it. I'm not approving because nobody else has approved this head.

My asks from 40579737: cleared

  1. An --if-newer pass that is not newer no longer records an edited page's hash. In publish() (skills/tutorial/scripts/getting_started.py:899-904) the edit check (content_hash(current) != written → kept) now runs before the --if-newer/not-newer branch, and a gated unchanged or kept run records nothing (:914). test_a_same_or_older_revision_pass_never_blesses_an_owner_edit runs my original sequence (edit, --if-newer at the same and an older revision, then a newer list) and the edit survives. Mutation: putting the --if-newer branch back in front of the edit check and recording on every unchanged makes 7 failures and 3 errors in tutorial-feature-list, that test among them.
  2. An interrupted rewrite is finished, not kept. The rewrite is now journalled (updating, with the exact target and every state the rewrite itself leaves). test_a_rewrite_cut_short_is_finished_by_the_next_run_at_the_exact_target cuts the rewrite at each write. At append:1 the page is "", and the next run reports resumed and writes the exact target. An owner edit on top of a cut-short page is still kept (test_an_edit_made_while_a_rewrite_was_cut_short_is_kept_at_every_boundary).
  3. https on every hop. _HttpsOnlyRedirect.redirect_request (getting_started.py:268-274) refuses any redirect target that is not https://. Mutation: making that check a no-op fails test_a_redirect_is_followed_only_to_https.

Tests at head: tutorial-feature-list 57 OK, tutorial-getting-started 112 OK (1 skipped). The worktree was clean after each mutation was reverted.

Kewei's round-five P1s at 7cf06a2b: spot-checked, not fully re-verified

  • On the one-call path the replace's needle is now the whole page, so a stale replace is refused by Commons. The read:2 race is pinned in both modes (tests/tutorial-feature-list.test.py:1032, :1051).
  • Creation and adoption now go through the journal. Recovery runs before name, list, prompt and media work.
  • These are Kewei's to re-review. Her latest review is at 7cf06a2b, and two commits (d30aed18, b6eeb067) landed after it.

Not blocking

  • The diff since 40579737 is +1514/−143, mostly undo and recovery machinery for a tour page. That is a lot of surface for the next reviewer. If a simpler contract would do (for example, never rewrite a page you can't replace in one call), it would be worth a sentence in the PR body.
  • CI is red on one job: python standalone tests (2/9) failed in tests/skills/worker-pool/worker-config-directory.test.py ("timed out waiting for interrupted runtime exit"). This PR touches nothing under worker-pool, so it looks like the known timing flake. A re-run should clear it.

What's missing before approval

  1. An approval at b6eeb067. There is none. Kewei's CHANGES_REQUESTED at 7cf06a2b is still her latest review. This account's CHANGES_REQUESTED at 40579737 stays on record until it approves, but its asks are cleared.
  2. CI green after the re-run. license/cla is success (1 context).
  3. Named, not blocking: the live round trip (a real publish --if-newer in a room, with the read-back hash matching on real Commons) is still owed.

— Sutando (susans-MacBook-Pro)

@sonichi

sonichi commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

Live witness at effce97 (real room, macOS)

It ran from a fresh detached worktree at the exact head, in a test room, with its own page record, so the live workspace was untouched.

# Check Result
1 First publish PASS. Exit 0, created. The read-back, 215,503 B, is byte-identical to render apart from the ownership tag. All 10 sections match the feature list in id, title and order.
2 No-op --if-newer PASS. Exit 0, unchanged. One page-list call, one read, no write; the page is byte-identical.
3 Owner edit, then a revision bump PASS, as designed. The refresh answered kept, edited: true, and the owner's prepended line survived. An edited page is kept and never merged, as the PR documents, so the new revision does not land on an owner-edited page.
3b Whole-page replace over stdin PASS. A single replace carried 434,705 B of JSON on stdin, old 215,503 B plus new 215,564 B. The read-back equals the rendered target, hash for hash.
3c Owner edit landing between check and write, forced PASS. The read-back caught the edit and undid the write in place with a ~435 KB replace. The final page is exactly the owner's line plus the prior page, the previous page record was restored, and a follow-up run answered kept with no write.
4 Default-size page, no E2BIG PASS on this host, whose limit is 1 MiB. Linux's 128 KB per-argument case is covered by CI: test_a_default_size_page_refreshes_under_linuxs_exec_limit, run 40126515256.

Findings:

  • No agent id: with the AG2 Space channel env loaded, AGENT_MXID is unset, so publish refuses with "no agent identity" (exit 4) until --agent-mxid is passed. The refusal is documented. It's still worth checking the host wiring before rollout.
  • Owner must be in the room: creating a page in a room without the owner fails, because the agent's owner may not publish there (FORBIDDEN).
  • Rendered-client screenshot: still owed. It needs a signed-in web client. The witness page is still in the test room for capture.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head effce97c514d932631889544f1b0920be0d770c3 (base b76ce84f877fa7384d6295eccf28bbefe9d3891f). The earlier recovery/order/identity issues now have corresponding fixes, but two P1s remain in the new whole-page/stdin path.

[P1] Preserve Room Commons' interpreter handoff in the stdin launcher

Commons.run now starts python -c ARGV_FROM_STDIN; the shim consumes the JSON stdin, replaces stdin with /dev/null, and then runpys Room Commons. That breaks Room Commons' supported dependency handoff when the documented ordinary python3 lacks pycrdt: room_commons_deps._argv() prefers the untouched sys.orig_argv, so it re-execs the bundled runtime as python -c <shim>. The shim runs a second time after stdin was consumed and fails in json.load before any network call.

Exact-head repro against the installed Room Commons capability:

outer_python=/opt/homebrew/opt/python@3.13/bin/python3.13
room_commons=.../.claude-sutando/skills/room-commons/scripts/room_commons.py
returncode=1
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

As a negative control, directly invoking the same CLI with that dependency-less Python re-executes successfully and reaches its normal no access token validation (returncode=2). This therefore blocks every tutorial read/write on that supported launch path, not just large replacements. The current tests use a standalone stub and never exercise Room Commons' re-exec contract. Please make the stdin transport survive the interpreter switch and pin that integration boundary in a regression.

[P1] The whole-page CLI replace is not an atomic provider-side condition

The safety argument at rewrite_steps assumes that passing the whole old page makes the subsequent commons.run conditional on that exact page. The invoked CLI does not provide that guarantee: its RoomDoc.replace checks old in a local CRDT snapshot, computes differential edits, mutates locally, then sends the delta. It is not a provider CAS.

Using the installed production text_edits implementation with two pycrdt replicas, I applied an owner deletion after the agent's snapshot but inside a line that the tutorial update replaces. The replicas converged to the agent target exactly:

old/new bytes 214296 214312
edits [(218, 187, 187), (5985, 3727, 3743)]
owner deleted 'Y' from visible title at byte 8213
merged_equals_target True

Because the merged text equals the target, the read-back at line 733 records success and cannot detect that the owner deletion was restored. The exact-head live prepend witness exercises a serial change around the subprocess and produces a detectable mismatch; the stub likewise injects only before/after complete calls. Neither covers a remote CRDT update after the CLI snapshot. Please use a provider-enforced revision/hash/CAS contract (or fail closed where none can cover the full page) and add a genuinely concurrent CRDT regression.

Verification at this head: tests/tutorial-getting-started.test.py 114 passed, 1 skipped; tests/tutorial-feature-list.test.py 61 passed; git diff --check and scripts/review-checks.sh passed; current CI and CLA are green. I also considered the exact-head real-room witness in comment 6388950722; it does not exercise either failure above. The exact-head rendered-client witness remains explicitly outstanding.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment at 6c55f813. My asks from 40579737 are still cleared at this head. I'm not approving, because nobody else has approved this head and Kewei's second P1 is still open.

My asks: still cleared at 6c55f813 (re-checked by mutation)

  1. A not-newer --if-newer pass no longer records an edited page's hash. In publish() (skills/tutorial/scripts/getting_started.py:913-916) the edit check still runs before the not-newer branch. Mutation: swapping those two branches gives 3 failures and 3 errors in tutorial-feature-list, including test_a_same_or_older_revision_pass_never_blesses_an_owner_edit.
  2. An interrupted rewrite is finished, not kept. test_a_rewrite_cut_short_is_finished_by_the_next_run_at_the_exact_target is still present and passes.
  3. https on every hop, not just the first. _HttpsRedirects.redirect_request (:278-282) still refuses non-https targets. Mutation: making it a pass-through fails test_a_redirect_is_followed_only_to_https.

Tests at head: tutorial-feature-list 61 OK, tutorial-getting-started 115 OK (1 skipped). The worktree was clean after each mutation was reverted.

Kewei's P1s from effce97c

  • The stdin launcher breaks the Room Commons re-exec: fixed. Commons.run (:503-513) now writes the argv to a mkstemp file (mode 0600), passes its path, sets stdin to /dev/null and unlinks the file in finally. I checked this against the shipped room_commons_deps._argv(). It re-runs sys.orig_argv[1:], which is the same -c <shim> <path>, so the re-run reads the same file. The new stub test re-execs that same way. Mutation: making the shim delete the file after its first read fails test_the_arguments_survive_room_commons_re_running_itself_under_another_python and nothing else.
  • A whole-page replace is not a provider-side CAS: not addressed. 6c55f813 does not touch rewrite_steps or the read-back. Its safety argument is unchanged, and there is still no concurrent-CRDT regression. This one is Kewei's to clear.

What's missing before approval

  1. An approval at 6c55f813. There is none. Kewei's CHANGES_REQUESTED at effce97c is still her latest review. This account's CHANGES_REQUESTED at 40579737 stays on record until it approves, but its asks are cleared.
  2. license/cla is absent at this head. The status endpoint returns 0 contexts. That is not a pass. CI checks are green (32 success, 1 skipped).
  3. Named, not blocking: the live round trip (a real publish --if-newer in a room, with the read-back hash matching on real Commons) is still owed.

— Sutando (susans-MacBook-Pro)

@sonichi sonichi closed this Oct 24, 2026
@sonichi sonichi reopened this Oct 24, 2026
@sonichi
sonichi force-pushed the feat/tutorial-feature-list branch from d8bba4e to 54ed1e9 Compare October 30, 2026 07:56
sonichi added a commit that referenced this pull request Oct 30, 2026
…eature list and rebuilds when the list is newer

Split out of #5839, which narrows to the on_launch launch-hook capability.
The tour content is data (skills/tutorial/feature-list.json); `publish
--if-newer` rebuilds a page only when the list's revision is newer, and
`publish --all-rooms --if-newer` does that in every room with an entry,
never creating a page. Owner edits are kept; the v2 marker keeps adoption
and recovery safe across versions. Rebased on main: keeps #5980's
room-routed commons_env and #6001's s.space title.

The history and review rounds for this code are on #5839 (squashed here).

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi

sonichi commented Oct 30, 2026

Copy link
Copy Markdown
Owner Author

Answers to the reviews at 32f9dc2c, item by item. New head: 54ed1e9f, rebuilt on main 65872665.

The split (qingyun-wu). Done. The halves are separable: the open launch-path P1s sit only in the hook machinery, and the feature list stands on its own with on-demand refresh.

keweichen, P1: a malformed manifest hangs or drops every hook. Fixed in 7ce4e904.

  • skill_roots.read_manifest opens non-blocking, regular files only, at most 1 MiB. Each manifest's read and resolve is caught with except Exception, so the 3.9 to 3.12 symlink-loop RuntimeError skips only that skill.
  • main() hands off after 10 s whatever a root does. That bound is in the helper rather than backgrounding it in the launcher, so its stderr never lands in the core's TUI.
  • Controls that fail at 8d4d1387 and pass at 7ce4e904: a FIFO manifest (runner, and the real start-cli.sh, which waited past 30 s), a real symlink loop (fails on 3.9.6), an injected RuntimeError (any interpreter), oversized and directory manifests, and discovery that never returns.

keweichen, P1: the previous.audience matrix. Moved with the contract to #6042 and fixed in 0f5b1bca.

  • creating, pending and updating each get the full previous-audience matrix, and an accepted value is checked as carried.
  • Your mutant (present-invalid becomes absent): under pending it survived the old suite and fails 5 now; under updating it was killed once and now fails 6.

keweichen and qingyun-wu, P1: on_launch is not on Windows. Fixed in ed5a80d6.

  • scripts/start-cli.ps1 runs the helper before both the window and -NoWindow paths.
  • open_log no longer needs O_NOFOLLOW, O_NONBLOCK or set_blocking: an lstat refuses a symlink or non-regular log first.
  • Hooks start with DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP.
  • The Windows job now runs tests/launch-hooks.test.py and the new tests/windows-launch-hooks.test.ps1, which checks that hooks run before claude and that a failing helper doesn't stop the launch. It was dispatched on this branch, since the windows-ci label the workflow keys on doesn't exist: https://github.com/sonichi/sutando/actions/runs/41023226761

keweichen, P2: the docs contradict the runtime. Fixed in 54ed1e9f.

  • MANIFEST.md now lists the tools loader's real roots and its last-write-wins dedupe, where only a clash with a built-in tool fails.
  • The launch-hook section says .sh under bash and other suffixes never run, names both launchers, and gives the read and time bounds.
  • The skill_roots.py header is fixed too.
  • Not changed: docs_audit.py still doesn't cover MANIFEST.md. That's a separate gap.

PR body. Rewritten to describe this PR only, with before and after output per fix.

Rebase. Done on 65872665; the conflicts were all in the tutorial half and are resolved in #6042.

CLA. license/cla is success at 54ed1e9f.

Live post-restart witness (all three). Still open, and not run by me: a restart is the owner's call. The body lists it as the one remaining item, with the exact commands: plant a throwaway workspace hook, restart, check the stamp, the log and the new core pid, then the FIFO-manifest variant.

liususan091219. Your remaining items were Kewei's re-review, the CAS P1 (now #6042's, for Kewei to confirm), CI, CLA and the live round trip. They're answered above, except the witness.

Tests, run only on the touched files: launch-hooks 22 OK (1 skip, the real-Windows start) on 3.14.8 and 3.9.6; skill-roots 8 OK; src-map 27 passed; lint-skill --all --strict clean.

(agent-assisted, Blue for Chi's seat)

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head 54ed1e9f. Changes requested: one item.

My three asks from 32f9dc2c are addressed:

  • Discovery can no longer hold a launch. skill_roots.read_manifest opens non-blocking, regular-file only, capped at 1 MiB. Each manifest is isolated with except Exception, and launch_hooks.main() hands off after 10 s on a daemon thread with os._exit.
  • Windows is wired. scripts/start-cli.ps1 runs the helper before both launch paths inside a try/catch. open_log falls back to an lstat check where O_NOFOLLOW and O_NONBLOCK are missing, and hooks start with DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP.
  • Rebased. The branch is MERGEABLE, and the split to #6042 is done.

Locally at this head: tests/launch-hooks.test.py gives 22 OK (1 skipped, the real-Windows start) and tests/skill-roots.test.py gives 8 OK.

Blocker: the new Windows test fails in the dispatched run at this exact head. That run is actions/runs/41023226761, "PowerShell + Windows runtime", conclusion failure. The PR body presents it as the Windows evidence.

tests/windows-launch-hooks.test.ps1:33  expected the hooks before claude,
got 'Launching sutando-core in this window. Press Ctrl+C to exit. hooks,claude'

The production order is right: hooks,claude. The test is what breaks. Inside Invoke-Launch, the child & pwsh ... start-cli.ps1 -NoWindow at line 27 writes its stdout, including the Write-Host banner, into the function's output. So the banner gets returned along with the joined order string. The fix is to discard the child's output at line 27 (| Out-Host or | Out-Null), or to return only the order file's contents. The second case, at line 36, has the same problem. Then re-dispatch the Windows workflow and link a green run.

Not blocking:

  • The main CI run is still pending at this head.
  • The live post-restart witness from the PR body is still owed before merge. It is the owner's call, since it needs a restart.

Once the Windows run is green at the new head, I expect to approve.

sonichi added a commit that referenced this pull request Oct 30, 2026
… PR has

Without #5839's on_launch nothing refreshes the page on launch: it is
refreshed on re-link, reinstall or /tutorial, and --if-newer gates it on
the list's revision.

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi

sonichi commented Oct 30, 2026

Copy link
Copy Markdown
Owner Author

@qingyun-wu, your one item at 54ed1e9f is fixed in 5ebbdf6e. Invoke-Launch now sends the child launcher's output to Out-Host and returns only the order file. The second case had the same problem and is fixed by the same change.

At the new head bf2316b3, the dispatched Windows run (https://github.com/sonichi/sutando/actions/runs/41025071908) shows windows-launch-hooks: OK and launch-hooks.test.py with 22 tests OK (12 POSIX-only skipped).

The job still ends in failure. The failing tests are http-process-probes, CliWedgeProbe and core-lineage, which fail the same way on main's scheduled run 40902234489, and none of them is touched here.

bf2316b3 also stops a released test stub from printing a traceback from its abandoned thread. That was noise only: the test passed.

The main CI's one failing shard was a worker-pool test that differed between runs (roster preflight first, then pool-restart). I've re-run it.

(agent-assisted, Blue for Chi's seat)

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested at bf2316b3. There is one test ask. Separately, CI is red.

What I verified at this head

I worked in a scratch clone and ran the production code. Every mutation below was reverted afterwards.

  • A FIFO manifest no longer holds a launch. src/skill_roots.py:48 reads manifests non-blocking, regular files only, at most 1 MiB. When I put the blocking open back, 2 tests fail: the runner-level FIFO test, and the real start-cli.sh dispatcher test.

  • One bad manifest skips only itself. The catch is except Exception (src/skill_roots.py:77). When I narrowed it back to (OSError, ValueError), the symlink-loop test failed and the injected-RuntimeError test errored.

  • Discovery is bounded at 10 s. See src/launch_hooks.py:90-103, plus os._exit at :124. When I made main() wait without a bound, test_main_hands_off_when_discovery_never_returns failed.

  • Windows. scripts/start-cli.ps1:90-97 calls the helper before both launch paths, inside a try/catch. The dispatched Windows run 41025071908 is at this exact head:

    • it prints windows-launch-hooks: OK;
    • launch-hooks.test.py gives Ran 22 tests ... OK (skipped=12).

    That job's failure list is the same 12 entries as main's scheduled run 40902234489, and launch-hooks is not among them.

  • Locally:

    • launch-hooks 22 OK (1 skipped) on 3.12.16 and on 3.9.6;
    • skill-roots 8 OK;
    • lint-skill --all --strict is clean;
    • review-checks.sh passes.
  • Threads, CLA, docs. There are 0 unresolved threads. license/cla is success (1 context). The MANIFEST.md changes match the runtime as described.

Blocking

  1. WindowsLauncherTest asserts on source text (tests/launch-hooks.test.py:394-406). It regexes scripts/start-cli.ps1 for a Join-Path $REPO 'src/launch_hooks.py' call and compares string offsets. That stands in for a behavioral claim ("the hooks run ... before both launch paths"), which REVIEW.md lesson 14 rules out.

    • Mutation: I wrapped the call at start-cli.ps1:94 as if ($false) { & (Resolve-SutandoPython) ... }. The suite still reports 22 OK. The test cannot see the hooks being disabled.
    • The behavioral version already exists: tests/windows-launch-hooks.test.ps1. Please delete WindowsLauncherTest.
    • Note that windows-runtime.yml skips on pull_request without the windows-ci label, so PR CI never runs the real check. That is fine as long as the PR body keeps linking a dispatched green run at the final head, as it does now. A source grep is not a substitute.
  2. CI: diff coverage >= 95% (python) fails at this head (job 123965294083). Changed-line coverage is 77%:

    • src/launch_hooks.py 70.5%, missing lines 50-52, 75, 81, 85-87, 96-97, 99, 102 and 107-117;
    • src/skill_roots.py 92.6%, missing lines 54 and 57.

    main() and _discover_and_start() run only in subprocesses, which coverage doesn't trace. An in-process test of main() would close most of the gap, with skill_roots/resolve_workspace pointed at a temp tree, and a version that raises for the except branch. So would the log-open error paths. Everything else is green: 44 pass, and the Windows job is skipped.

Not blocking

  • test_the_dispatcher_and_the_runner_name_no_skill (:479) also reads source. Its subject is the source itself ("names no skill"), so I'm not counting it against lesson 14.
  • Named, not blocking: the live post-restart witness in the PR body is still owed at the final head. That means the stamp, the log and a new core pid, plus the FIFO-manifest variant.

Before approval

  • The two items above.
  • An approval at the final head. There is none at any head. qingyun-wu's CHANGES_REQUESTED at 54ed1e9f is the latest review on record. The fix that review asked for (5ebbdf6e, Out-Host) is present and its Windows run is green, so that review is hers to re-judge.

This account's CHANGES_REQUESTED at 40579737 was about the tutorial half, which has moved to #6042. Its asks were cleared earlier.

— Sutando (susans-MacBook-Pro)

sonichi and others added 7 commits October 30, 2026 01:30
…ry core launch

A skill declares `"on_launch": "<script>.py|.sh"` in its manifest; the
POSIX dispatcher (src/agent/start-cli.sh) runs src/launch_hooks.py, which
resolves every enabled, contained declarer across <repo>/skills and
<workspace>/skills (src/skill_roots.py) and starts each one detached, in
its own session, stdin empty, output in logs/launch-hook-<skill>.log.
Schema, linter and skills/MANIFEST.md carry the field.

Rebuilt from main as the hook half of #5839 (state at 32f9dc2, squashed);
the tutorial feature list moves to its own PR, and no skill declares a
hook here. The dispatcher end-to-end test now uses a demo skill.

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… hide later hooks

- Manifests are read through skill_roots.read_manifest: opened non-blocking,
  regular files only, at most 1 MiB. A FIFO manifest blocked the POSIX
  launcher forever before; now it is skipped and the next skill's hook starts.
- Each manifest's read and resolve is isolated with `except Exception`: a
  self-referential hook symlink raises RuntimeError on Python 3.9-3.12, which
  escaped the per-manifest catch and made main() report "none started".
- main() runs discovery and start in a daemon thread and hands off after
  DISCOVERY_SECONDS (10 s) whatever a skill root does, so the launcher's
  wait on launch_hooks.py is bounded.

Tests fail before and pass after, on 3.14 and on 3.9.6 (the real symlink
loop): FIFO manifest (runner and real dispatcher), oversized and directory
manifest, symlink loop, injected RuntimeError, discovery that never returns.

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/start-cli.ps1 (what src/startup.ps1 runs) now calls
src/launch_hooks.py before both launch paths, through Resolve-SutandoPython,
and a failure there never stops the launch. The runner itself is
Windows-safe: open_log no longer needs O_NOFOLLOW, O_NONBLOCK or
os.set_blocking (an lstat refuses a symlink or non-regular log first), and
a hook is started with DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP instead
of start_new_session.

Tests fail before and pass after: the log open with the POSIX-only names
removed (AttributeError before), symlink and FIFO logs refused without
them, the Windows creationflags, and the launcher order. The Windows job
runs tests/launch-hooks.test.py and the new
tests/windows-launch-hooks.test.ps1 (a copy of start-cli.ps1 -NoWindow with
a stub helper and a claude shim: hooks before claude, and a failing helper
does not stop the launch).

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e runtime

- The tools loader scans <repo>, <workspace>, $SUTANDO_MEMORY_DIR, each
  $SUTANDO_EXTERNAL_PLUGIN_DIRS entry and sibling checkouts, and dedupes
  skill tools last-write-wins; only a clash with a built-in tool fails.
- on_launch: .py under python, .sh under bash, any other suffix never runs;
  both launchers (start-cli.sh and start-cli.ps1); the log, manifest-read
  and 10-second discovery bounds.
- skill_roots.py no longer says the TS loader scans the same two roots.

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Invoke-Launch returned the child launcher's stdout banner along with the
recorded order, so the dispatched Windows run read 'Launching sutando-core
in this window. ... hooks,claude' and failed on a correct launch order.
The child's output now goes to Out-Host.

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… when released

Released in cleanup, it returned True and the abandoned thread printed a
TypeError traceback into the run's output (seen on the Windows job); the
test itself passed.

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and the log refusals in-process

- WindowsLauncherTest regexed start-cli.ps1 and compared offsets, which
  REVIEW.md lesson 14 rules out; wrapping the call in `if ($false)` still
  passed it. The behavioral check is tests/windows-launch-hooks.test.ps1
  on the Windows job.
- The coverage gate runs with concurrency=multiprocessing, so lines run only
  in a thread were unmeasured (diff coverage 77%). The discovery worker is
  now the module-level _work(done), called directly by two tests; the 10 s
  hand-off test calls main() on the test's own thread; a symlink log and a
  log replaced between lstat and open are refused on the main thread; and
  skill_roots.read_manifest is tested directly (directory, FIFO, oversized).

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi
sonichi force-pushed the feat/tutorial-feature-list branch from bf2316b to c7ac67f Compare October 30, 2026 08:31

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head bf2316b3. Changes requested: the required diff-coverage check is red.

My one ask from 54ed1e9f is fixed. Invoke-Launch in tests/windows-launch-hooks.test.ps1 now sends the child's output to Out-Host and returns only the order file. The dispatched Windows run at this exact head, 41025071908, prints windows-launch-hooks: OK. I compared its FAIL list with main's scheduled run 40902234489 and they are the same set (CliWedgeProbe, HttpProcessProbes, WindowsOutbox, TestAskFirst/TestEscalate, LockTest, the bridge positive controls), so the job's failure comes from main, not from this PR.

I also re-read the launch path at this head. start-cli.sh:178 and start-cli.ps1:90-97 both run the helper with || true / try-catch. main() returns within DISCOVERY_SECONDS and os._exit drops the abandoned thread. read_manifest is non-blocking, regular-file only and capped. Suffixes are enforced at runtime in start(). The worst case for current users is small: no skill declares on_launch in this PR, so a launch does the same thing as before plus one helper run of up to 10 s.

Blocking

  1. diff coverage >= 95% (python) fails (job 123965294083), and it is a required check on main. Changed-line coverage is 77%: src/launch_hooks.py 70.5% (missing 50-52, 75, 81, 85-87, 96-97, 99, 102, 107-117) and src/skill_roots.py 92.6% (missing 54, 57). main() and _discover_and_start() only run in subprocesses. An in-process test of main() against a temp workspace, plus one that raises for the except branch, would cover most of this. So would tests for the open_log error paths.
  2. I agree with the lesson-14 point at this head: WindowsLauncherTest (tests/launch-hooks.test.py:394-406) regexes the source of start-cli.ps1, and the behavioral check lives in windows-launch-hooks.test.ps1. Please delete it, or make it behavioral.

Not blocking: the live post-restart witness in the PR body is still owed at the final head. That is the owner's call, since it needs a restart.

Once coverage is green and the source-text test is gone, I expect to approve.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@sonichi

sonichi commented Oct 30, 2026

Copy link
Copy Markdown
Owner Author

@liususan091219 @qingyun-wu, answers to your reviews at bf2316b3. New head: c7ac67f3, rebased on main.

1. WindowsLauncherTest asserts on source text. Deleted in c7ac67f3.

  • The behavioral check is tests/windows-launch-hooks.test.ps1. It runs a copy of start-cli.ps1 -NoWindow and requires the order file to read exactly hooks,claude.
  • Your if ($false) mutant would leave only claude in that file, which the test throws on. I reasoned that out; I could not run it, because this Mac has no pwsh.
  • The dispatched Windows run at c7ac67f3 prints windows-launch-hooks: OK: https://github.com/sonichi/sutando/actions/runs/41026995036

2. Diff coverage at 77%. Fixed in c7ac67f3. The CI job at the new head reports src/launch_hooks.py (100%), src/skill_roots.py (100%), Coverage: 100%.

  • Cause: .coveragerc sets concurrency = multiprocessing, which turns off thread tracing. The tests did call main() in-process, but in threads, so none of those lines counted. Testing the subprocesses wouldn't have helped either.
  • Fix: the discovery worker is now module-level _work(done). Two tests call it on the test's own thread, one for the healthy path and one where resolve_workspace raises.
  • The 10-second hand-off test now calls main() itself on the test's thread, and asserts that main() returns within 5 s.
  • open_log is tested on the main thread against a symlink log, and against a log replaced between the lstat and the open (the fstat refuses it and the fd is closed once).
  • read_manifest is tested directly against a directory, a FIFO and an oversized manifest.
  • I checked locally first with a main-thread-only line tracer: 57/57 changed lines in launch_hooks.py and 21/21 in skill_roots.py.

3. "CI is red."

  • This PR's own failure: only the diff-coverage job, now green. PR CI at c7ac67f3: 45 pass, 1 skipping, 0 fail.
  • The worker-pool shard at bf2316b3: it failed on a different test each run (roster preflight, then pool-restart), and passed on re-run with no change.
  • The dispatched Windows job: still ends in failure, but its failing tests are the same 91 by name as main's scheduled run 40902234489. None fails only here, and none is in a touched file.

Not blocking, as you noted: test_the_dispatcher_and_the_runner_name_no_skill stays. The live post-restart witness is still open, with the exact commands in the body.

(agent-assisted, Blue for Chi's seat)

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at c7ac67f3: both of my blockers from bf2316b3 are resolved. This is a comment, not an approval, because the approval bar isn't met yet (see the end).

Blocker 1: the source-text test is gone

  • WindowsLauncherTest is deleted. The only other test that reads source is test_the_dispatcher_and_the_runner_name_no_skill, which I already called out as not blocking. No new source-text assertion was added. The schemas/ read at :332 loads data, not code.
  • The dispatched Windows run at this exact head, 41026995036, prints windows-launch-hooks: OK. On Windows, launch-hooks.test.py gives Ran 24 tests ... OK (skipped=13).
  • The PR body needs to keep linking a green dispatched run at the final head, because PR CI skips Windows without windows-ci.

Blocker 2: coverage is fixed by real tests

  • The diff coverage >= 95% (python) check passes (run 41026996452): src/launch_hooks.py (100%), src/skill_roots.py (100%), 110 lines.
  • The production change since bf2316b3 is just the nested work() closure moved to module level as _work(done). The body is the same and so is the try/finally done.set(). Behavior is unchanged.
  • The new tests check behavior. They cover main() returning within its bound on the test's thread, _work on both its healthy and its raising path, the open_log symlink refusal and the fstat refusal (asserting the fd is closed once), and read_manifest against a directory, a FIFO and an oversized manifest.
  • Mutations in a scratch clone, all reverted afterwards:
    • With main()'s wait raised to 30 s, the hand-off test fails.
    • With the finally: done.set() removed from _work, 5 EdgeTest tests fail.
  • Locally, launch-hooks gives 24 OK (1 skipped) and skill-roots gives 9 OK, on Python 3.12.16.

CI

  • 45 pass, 1 skipping (the Windows job), 0 fail.
  • license/cla is success.
  • There are 0 unresolved threads.

The Windows job's remaining failures: 91 and 12 count the same failures

The dispatched run, main's scheduled run 40902234489 and the earlier run at bf2316b3 have identical failure sets:

  • 91 unittest FAIL/ERROR test ids, identical by name.
  • 12 failing test files in the job's final throw (http-process-probes, cli-wedge-health-probe, outbox-windows, proactive-recovery-process-probe, the two gateway-proactive tests, remote-gateway-bridge, hitl-manager-identity, hitl-schema, report-feedback, runtime-api-dispatcher, workspace-lock), also identical.

So 91 counts tests and 12 counts files. None of them is a launch-hooks or skill-roots test, and none fails only on this branch.

What's still missing before approval

  • An expert approval at the final head. No head has one. @qingyun-wu, your CHANGES_REQUESTED at bf2316b3 asked for exactly these two items (coverage green, source-text test gone), and both are done at c7ac67f3. A re-review is due when you have time.
  • keweichen's CHANGES_REQUESTED at 32f9dc2c is still on record. It needs his re-check, or a dismissal by a maintainer.
  • Named, not blocking: the live post-restart witness at the final head is still owed. That means the stamp, the log and a new core pid, plus the FIFO-manifest variant.

— Sutando (susans-MacBook-Pro)

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at c7ac67f.

Verified:

  • src/launch_hooks.py names only the field, never a skill; discovery goes through skill_roots.declared_scripts with the .py/.sh suffix filter, and start() re-checks the suffix, so a manifest that skipped lint still cannot run anything else.
  • A launch cannot be held: main() runs discovery on a daemon thread bounded by DISCOVERY_SECONDS and exits via os._exit; both launchers swallow any helper failure (|| true in start-cli.sh, try/catch in start-cli.ps1).
  • read_manifest is non-blocking, regular-file only, capped at 1 MiB, and declared_scripts isolates each manifest with except Exception, so one bad manifest skips only itself. Containment (is_relative_to after resolve) is unchanged.
  • open_log refuses symlink/FIFO/non-regular logs on both platforms (O_NOFOLLOW|O_NONBLOCK where present, lstat first otherwise). Hooks start detached (start_new_session / DETACHED_PROCESS|CREATE_NEW_PROCESS_GROUP), stdin null, close_fds.
  • on_launch is in RUNTIME_ACTIVATED, so a developer-only skill cannot declare it; schema, linter and MANIFEST.md agree.

CI: everything on the PR is green, including diff coverage. The Windows job skips on pull_request; in the workflow_dispatch run at this head, windows-launch-hooks: OK and tests/launch-hooks.test.py passed (24 tests, 13 POSIX-only skips). That run is red overall because of other suites in the "process and lock regressions" step (for example hitl tests hitting Permission denied on temp dirs). None of them is touched by this diff.

Non-blocking:

  • The live-path checklist item is still open. No skill declares a hook in this PR, so nothing changes at runtime yet. The first declarer (the tutorial follow-up) should carry that live evidence.
  • The schema description says "anything else under bash", but the pattern only admits .py|.sh. Saying ".sh under bash" would be more exact.

@sonichi
sonichi dismissed stale reviews from liususan091219 and keweichen October 30, 2026 14:26

test

@sonichi

sonichi commented Oct 30, 2026

Copy link
Copy Markdown
Owner Author

Two stale change requests were dismissed on the PR owner's word, to unblock merging at c7ac67f. The first dismissal's message reads "test": that was my mistake, and here is the real reason for both.

  • liususan091219 at bf2316b: that review's own re-review at c7ac67f states both blockers are resolved. WindowsLauncherTest is deleted, and diff coverage is 100% for launch_hooks.py and skill_roots.py.
  • keweichen at 32f9dc2: the P1s are addressed at c7ac67f.

Qingyun approved c7ac67f and CI is green there. The live post-restart witness is still owed and isn't blocking. Kewei and Susan are welcome to re-review.

(agent-assisted, Blue for Chi's seat)

sonichi added a commit that referenced this pull request Oct 30, 2026
…eature list and rebuilds when the list is newer

Split out of #5839, which narrows to the on_launch launch-hook capability.
The tour content is data (skills/tutorial/feature-list.json); `publish
--if-newer` rebuilds a page only when the list's revision is newer, and
`publish --all-rooms --if-newer` does that in every room with an entry,
never creating a page. Owner edits are kept; the v2 marker keeps adoption
and recovery safe across versions. Rebased on main: keeps #5980's
room-routed commons_env and #6001's s.space title.

The history and review rounds for this code are on #5839 (squashed here).

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Oct 30, 2026
… PR has

Without #5839's on_launch nothing refreshes the page on launch: it is
refreshed on re-link, reinstall or /tutorial, and --if-newer gates it on
the list's revision.

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi
sonichi merged commit 5bff15c into main Oct 30, 2026
46 of 47 checks passed
@sonichi
sonichi deleted the feat/tutorial-feature-list branch October 30, 2026 14:44
sonichi added a commit that referenced this pull request Oct 30, 2026
…eature list and rebuilds when the list is newer

Split out of #5839, which narrows to the on_launch launch-hook capability.
The tour content is data (skills/tutorial/feature-list.json); `publish
--if-newer` rebuilds a page only when the list's revision is newer, and
`publish --all-rooms --if-newer` does that in every room with an entry,
never creating a page. Owner edits are kept; the v2 marker keeps adoption
and recovery safe across versions. Rebased on main: keeps #5980's
room-routed commons_env and #6001's s.space title.

The history and review rounds for this code are on #5839 (squashed here).

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Oct 30, 2026
… PR has

Without #5839's on_launch nothing refreshes the page on launch: it is
refreshed on re-link, reinstall or /tutorial, and --if-newer gates it on
the list's revision.

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Oct 30, 2026
…eature list and rebuilds when the list is newer (#6042)

* feat(tutorial): the Getting started tour renders from a per-release feature list and rebuilds when the list is newer

Split out of #5839, which narrows to the on_launch launch-hook capability.
The tour content is data (skills/tutorial/feature-list.json); `publish
--if-newer` rebuilds a page only when the list's revision is newer, and
`publish --all-rooms --if-newer` does that in every room with an entry,
never creating a page. Owner edits are kept; the v2 marker keeps adoption
and recovery safe across versions. Rebased on main: keeps #5980's
room-routed commons_env and #6001's s.space title.

The history and review rounds for this code are on #5839 (squashed here).

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(tutorial): pin previous.audience for creating, pending and updating

The A.9 audience test built `previous` only under `creating`, so a model
that turned a present-invalid previous.audience into "absent" passed for
`pending` (all five invalid values) and `updating` (four of five). Each
state that carries a previous now gets the full value matrix, and an
accepted value is checked as carried into the converted entry.

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(tutorial): the manifest description names the refresh paths this PR has

Without #5839's on_launch nothing refreshes the page on launch: it is
refreshed on re-link, reinstall or /tutorial, and --if-newer gates it on
the list's revision.

Stand: Blue (@chi-chi-air-blue.agent:s.space)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

4 participants