sigit-code 1.6.8__tar.gz → 1.6.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. {sigit_code-1.6.8 → sigit_code-1.6.9}/.agents/AGENTS.md +7 -2
  2. {sigit_code-1.6.8 → sigit_code-1.6.9}/.agents/skills/sigit-code-release/SKILL.md +41 -5
  3. {sigit_code-1.6.8 → sigit_code-1.6.9}/.claude/skills/sigit-code-release/SKILL.md +41 -5
  4. {sigit_code-1.6.8 → sigit_code-1.6.9}/.github/workflows/release-winget.yml +12 -4
  5. {sigit_code-1.6.8 → sigit_code-1.6.9}/AGENTS.md +7 -2
  6. {sigit_code-1.6.8 → sigit_code-1.6.9}/CHANGELOG.md +22 -0
  7. {sigit_code-1.6.8 → sigit_code-1.6.9}/CLAUDE.md +7 -2
  8. {sigit_code-1.6.8 → sigit_code-1.6.9}/Cargo.lock +1 -1
  9. {sigit_code-1.6.8 → sigit_code-1.6.9}/Cargo.toml +1 -1
  10. {sigit_code-1.6.8 → sigit_code-1.6.9}/PKG-INFO +1 -1
  11. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/inline_tool_calls.rs +177 -0
  12. {sigit_code-1.6.8 → sigit_code-1.6.9}/.agents/skills/agent-client-protocol/SKILL.md +0 -0
  13. {sigit_code-1.6.8 → sigit_code-1.6.9}/.agents/skills/ai-assisted-coding/SKILL.md +0 -0
  14. {sigit_code-1.6.8 → sigit_code-1.6.9}/.agents/skills/branding/SKILL.md +0 -0
  15. {sigit_code-1.6.8 → sigit_code-1.6.9}/.agents/skills/run-sigit/SKILL.md +0 -0
  16. {sigit_code-1.6.8 → sigit_code-1.6.9}/.agents/skills/run-sigit/driver.mjs +0 -0
  17. {sigit_code-1.6.8 → sigit_code-1.6.9}/.agents/skills/run-sigit/tui-smoke.sh +0 -0
  18. {sigit_code-1.6.8 → sigit_code-1.6.9}/.agents/skills/tool-calling/SKILL.md +0 -0
  19. {sigit_code-1.6.8 → sigit_code-1.6.9}/.cargo/config.toml +0 -0
  20. {sigit_code-1.6.8 → sigit_code-1.6.9}/.claude/skills/agent-client-protocol/SKILL.md +0 -0
  21. {sigit_code-1.6.8 → sigit_code-1.6.9}/.claude/skills/ai-assisted-coding/SKILL.md +0 -0
  22. {sigit_code-1.6.8 → sigit_code-1.6.9}/.claude/skills/branding/SKILL.md +0 -0
  23. {sigit_code-1.6.8 → sigit_code-1.6.9}/.claude/skills/run-sigit/SKILL.md +0 -0
  24. {sigit_code-1.6.8 → sigit_code-1.6.9}/.claude/skills/run-sigit/driver.mjs +0 -0
  25. {sigit_code-1.6.8 → sigit_code-1.6.9}/.claude/skills/run-sigit/tui-smoke.sh +0 -0
  26. {sigit_code-1.6.8 → sigit_code-1.6.9}/.claude/skills/tool-calling/SKILL.md +0 -0
  27. {sigit_code-1.6.8 → sigit_code-1.6.9}/.github/workflows/ci.yml +0 -0
  28. {sigit_code-1.6.8 → sigit_code-1.6.9}/.github/workflows/release-aur.yml +0 -0
  29. {sigit_code-1.6.8 → sigit_code-1.6.9}/.github/workflows/release-crates.yml +0 -0
  30. {sigit_code-1.6.8 → sigit_code-1.6.9}/.github/workflows/release-github.yml +0 -0
  31. {sigit_code-1.6.8 → sigit_code-1.6.9}/.github/workflows/release-homebrew.yml +0 -0
  32. {sigit_code-1.6.8 → sigit_code-1.6.9}/.github/workflows/release-npm.yml +0 -0
  33. {sigit_code-1.6.8 → sigit_code-1.6.9}/.github/workflows/release-nuget.yml +0 -0
  34. {sigit_code-1.6.8 → sigit_code-1.6.9}/.github/workflows/release-pypi.yml +0 -0
  35. {sigit_code-1.6.8 → sigit_code-1.6.9}/.github/workflows/release-scoop.yml +0 -0
  36. {sigit_code-1.6.8 → sigit_code-1.6.9}/.gitignore +0 -0
  37. {sigit_code-1.6.8 → sigit_code-1.6.9}/.nvmrc +0 -0
  38. {sigit_code-1.6.8 → sigit_code-1.6.9}/.sigit/code-cloud-agent/config.toml +0 -0
  39. {sigit_code-1.6.8 → sigit_code-1.6.9}/.sigit/code-review/config.toml +0 -0
  40. {sigit_code-1.6.8 → sigit_code-1.6.9}/.sigit/factory/config.toml +0 -0
  41. {sigit_code-1.6.8 → sigit_code-1.6.9}/LICENSE +0 -0
  42. {sigit_code-1.6.8 → sigit_code-1.6.9}/README.md +0 -0
  43. {sigit_code-1.6.8 → sigit_code-1.6.9}/docs/headless.md +0 -0
  44. {sigit_code-1.6.8 → sigit_code-1.6.9}/docs/hooks.md +0 -0
  45. {sigit_code-1.6.8 → sigit_code-1.6.9}/docs/mcp.md +0 -0
  46. {sigit_code-1.6.8 → sigit_code-1.6.9}/examples/settings-with-hooks.toml +0 -0
  47. {sigit_code-1.6.8 → sigit_code-1.6.9}/examples/skills/README.md +0 -0
  48. {sigit_code-1.6.8 → sigit_code-1.6.9}/examples/skills/commit-message/SKILL.md +0 -0
  49. {sigit_code-1.6.8 → sigit_code-1.6.9}/npm/README.md.tmpl +0 -0
  50. {sigit_code-1.6.8 → sigit_code-1.6.9}/npm/package-compat.json.tmpl +0 -0
  51. {sigit_code-1.6.8 → sigit_code-1.6.9}/npm/package-main.json.tmpl +0 -0
  52. {sigit_code-1.6.8 → sigit_code-1.6.9}/npm/package.json.tmpl +0 -0
  53. {sigit_code-1.6.8 → sigit_code-1.6.9}/npm/scripts/render-main-package.cjs +0 -0
  54. {sigit_code-1.6.8 → sigit_code-1.6.9}/npm/scripts/render-platform-package.cjs +0 -0
  55. {sigit_code-1.6.8 → sigit_code-1.6.9}/npm/sigit/.gitignore +0 -0
  56. {sigit_code-1.6.8 → sigit_code-1.6.9}/npm/sigit/README.md +0 -0
  57. {sigit_code-1.6.8 → sigit_code-1.6.9}/npm/sigit/package.json +0 -0
  58. {sigit_code-1.6.8 → sigit_code-1.6.9}/npm/sigit/src/index.ts +0 -0
  59. {sigit_code-1.6.8 → sigit_code-1.6.9}/npm/sigit/tsconfig.json +0 -0
  60. {sigit_code-1.6.8 → sigit_code-1.6.9}/nuget/.gitignore +0 -0
  61. {sigit_code-1.6.8 → sigit_code-1.6.9}/nuget/sigit/Program.cs +0 -0
  62. {sigit_code-1.6.8 → sigit_code-1.6.9}/nuget/sigit/README.md +0 -0
  63. {sigit_code-1.6.8 → sigit_code-1.6.9}/nuget/sigit/SiGit.Code.csproj +0 -0
  64. {sigit_code-1.6.8 → sigit_code-1.6.9}/packaging/aur/PKGBUILD.in +0 -0
  65. {sigit_code-1.6.8 → sigit_code-1.6.9}/packaging/nfpm.yaml +0 -0
  66. {sigit_code-1.6.8 → sigit_code-1.6.9}/packaging/windows/check-static-crt.ps1 +0 -0
  67. {sigit_code-1.6.8 → sigit_code-1.6.9}/packaging/winget/getSigit.siGitCode.installer.yaml.in +0 -0
  68. {sigit_code-1.6.8 → sigit_code-1.6.9}/packaging/winget/getSigit.siGitCode.locale.en-US.yaml.in +0 -0
  69. {sigit_code-1.6.8 → sigit_code-1.6.9}/packaging/winget/getSigit.siGitCode.yaml.in +0 -0
  70. {sigit_code-1.6.8 → sigit_code-1.6.9}/pypi/README.md +0 -0
  71. {sigit_code-1.6.8 → sigit_code-1.6.9}/pypi/pyproject.toml +0 -0
  72. {sigit_code-1.6.8 → sigit_code-1.6.9}/pyproject.toml +0 -0
  73. {sigit_code-1.6.8 → sigit_code-1.6.9}/rust-toolchain.toml +0 -0
  74. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/account.rs +0 -0
  75. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/backend.rs +0 -0
  76. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/browser_auth.rs +0 -0
  77. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/chat.rs +0 -0
  78. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/client_fs.rs +0 -0
  79. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/client_terminal.rs +0 -0
  80. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/commands.rs +0 -0
  81. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/credentials.rs +0 -0
  82. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/frontmatter.rs +0 -0
  83. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/harness_markup.rs +0 -0
  84. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/headless.rs +0 -0
  85. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/hooks.rs +0 -0
  86. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/instructions.rs +0 -0
  87. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/main.rs +0 -0
  88. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/mcp.rs +0 -0
  89. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/models.rs +0 -0
  90. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/permissions.rs +0 -0
  91. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/provider.rs +0 -0
  92. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/repetition.rs +0 -0
  93. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/session_store.rs +0 -0
  94. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/settings.rs +0 -0
  95. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/setup.rs +0 -0
  96. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/skills.rs +0 -0
  97. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/subagents.rs +0 -0
  98. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/tools.rs +0 -0
  99. {sigit_code-1.6.8 → sigit_code-1.6.9}/src/workspace.rs +0 -0
  100. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_audio.rs +0 -0
  101. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_client_fs.rs +0 -0
  102. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_client_terminal.rs +0 -0
  103. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_concurrent_sessions.rs +0 -0
  104. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_embedded_context.rs +0 -0
  105. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_endpoint_errors.rs +0 -0
  106. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_harness_markup.rs +0 -0
  107. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_images.rs +0 -0
  108. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_logout.rs +0 -0
  109. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_multi_root.rs +0 -0
  110. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_permissions.rs +0 -0
  111. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_session_list.rs +0 -0
  112. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_session_load.rs +0 -0
  113. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_stop_reasons.rs +0 -0
  114. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_tool_call_name.rs +0 -0
  115. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/acp_tool_stdin.rs +0 -0
  116. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/headless_mode.rs +0 -0
  117. {sigit_code-1.6.8 → sigit_code-1.6.9}/tests/version_flag.rs +0 -0
@@ -502,8 +502,13 @@ Three of these need credentials or a one-time manual step before they work:
502
502
 
503
503
  - Scoop needs a `getsigit/scoop-bucket` repo and a `SCOOP_BUCKET_TOKEN` secret, mirroring the
504
504
  Homebrew tap setup.
505
- - winget needs a `WINGET_TOKEN` (PAT with `public_repo` scope) so `wingetcreate` can fork
506
- `microsoft/winget-pkgs` and open the manifest PR. It is passed as
505
+ - winget files a Package Request issue first and then opens the manifest PR that resolves it,
506
+ authored by two separate classic PATs (replacing the old single `WINGET_TOKEN`):
507
+ `WINGET_ISSUE_TOKEN` (`public_repo`) for the issue and `WINGET_PR_TOKEN` for the PR.
508
+ `WINGET_PR_TOKEN` needs `public_repo` plus `read:org` and `read:discussion`: `public_repo`
509
+ lets `wingetcreate` fork `microsoft/winget-pkgs` and open the PR, and the later `gh pr edit`
510
+ that sets the PR body runs a GraphQL query resolving org/team and discussion fields that
511
+ fails with a scope error on a `public_repo`-only token. The PR token is passed as
507
512
  `WINGET_CREATE_GITHUB_TOKEN` rather than `--token`, which wingetcreate warns can leak the
508
513
  token into logs. `wingetcreate update` only works on a package that already exists in
509
514
  `microsoft/winget-pkgs`, so the workflow checks the `manifests/g/getSigit/siGitCode` path
@@ -38,12 +38,18 @@ Use this skill when preparing a release for this repository.
38
38
  - The GitHub release body is generated, not hand-written: `release-github.yml`'s release job checks out the tag, awk-extracts the `## <version>` section from `CHANGELOG.md` (a missing section is a hard failure), appends a full-changelog link and a `sigit.si/code` footnote, and publishes it via `body_path: release_notes.md`. The only file a release-prep change needs to touch is `CHANGELOG.md` — never edit release notes in the GitHub UI, because the next tag push would overwrite them.
39
39
  - npm and NuGet publish via OIDC trusted publishing — neither has a stored token in the repo. On npm this is configured per package (each `@getsigit/*` name has a trusted publisher pointing at `release-npm.yml`), so a new package needs the same setup before its first publish will work. OIDC requires npm >= 11.5.1 and Node >= 22.14.0.
40
40
  - The NuGet package (`SiGit.Code`, `dotnet tool install --global SiGit.Code`) bundles all six platform binaries under `native/<os>-<arch>/` behind a managed shim, rather than shipping one package per platform like npm/PyPI. Watch the nuget.org 250 MB package-size limit if it ever grows.
41
- - The winget channel (`release-winget.yml`) needs a `WINGET_TOKEN` secret that is a **classic PAT** with `public_repo` scope — not a fine-grained PAT. wingetcreate forks `microsoft/winget-pkgs` on first use and submits a PR from that fork; a fine-grained PAT scoped to `getsigit/sigit` cannot fork a repo outside its scope and every release fails with a 403/404 on the fork step. The token is passed as `WINGET_CREATE_GITHUB_TOKEN` (not `--token`) so it doesn't leak into logs. `wingetcreate update` only works on a package that already exists in `microsoft/winget-pkgs`, so the workflow checks `manifests/g/getSigit/siGitCode` first and falls back to `wingetcreate submit` with `packaging/winget/*.yaml.in` for the first release; every later release takes the `update` path. If a winget release fails with a fork/permission error, check the `WINGET_TOKEN` secret type before re-dispatching.
41
+ - The winget channel (`release-winget.yml`) files a Package Request issue first and then opens the manifest PR that resolves it, authored by **two separate classic PATs** (not fine-grained) — `WINGET_ISSUE_TOKEN` for the issue and `WINGET_PR_TOKEN` for the PR, replacing the old single `WINGET_TOKEN`. `WINGET_ISSUE_TOKEN` needs `public_repo`. `WINGET_PR_TOKEN` needs `public_repo` **plus `read:org` and `read:discussion`**: wingetcreate forks `microsoft/winget-pkgs` and opens the PR (fork needs `public_repo`; a fine-grained PAT scoped to `getsigit/sigit` can't fork an outside repo and 403/404s), and the "Set the winget PR body" step then runs `gh pr edit`, whose GraphQL query resolves org/team and discussion fields and fails with a scope error on a `public_repo`-only token (CI run 38262301933). The PR token is passed as `WINGET_CREATE_GITHUB_TOKEN` (not `--token`) so it doesn't leak into logs. `wingetcreate update` only works on a package that already exists in `microsoft/winget-pkgs`, so the workflow checks `manifests/g/getSigit/siGitCode` first and falls back to `wingetcreate submit` with `packaging/winget/*.yaml.in` for the first release; every later release takes the `update` path. If a winget release fails on the fork step, check the token type; if it fails on the `gh pr edit` step with a scope error, check `WINGET_PR_TOKEN`'s scopes before re-dispatching.
42
42
  - The baked-in official MCP server is separately listed in the public MCP Registry as `si.sigit/sigit` (`release-mcp-registry.yml`, driven by `server.json` at the repo root). It's a remote Streamable-HTTP listing, so it's verified by a DNS TXT record on the `sigit.si` domain rather than the GitHub-OIDC scheme used for package listings.
43
43
 
44
44
  ## Git release flow
45
45
 
46
- Releases are cut from `development` and shipped on `main`. The published tags (`v1.2.0`, `v1.2.1`) point at the `main`-side merge commit, never at a release branch or at `development`. Follow this order:
46
+ Releases are shipped on `main`, and the published tags (`v1.2.0`, `v1.2.1`) point at the
47
+ `main`-side merge commit, never at a release branch or at `development`. There are two flows,
48
+ and picking the wrong one ships the wrong set of commits. Decide first which this is.
49
+
50
+ ### Full release (minor/major — ships everything on `development`)
51
+
52
+ Cut from `development` when the release is meant to carry all the work that has accumulated there.
47
53
 
48
54
  1. Branch `release/v<version>` off `development`.
49
55
  2. Apply the version bump and changelog edits, then commit (e.g. `Release v<version>`).
@@ -51,10 +57,38 @@ Releases are cut from `development` and shipped on `main`. The published tags (`
51
57
  4. Merge `development` into `main` (commit message: `Merge development into main for v<version> release`).
52
58
  5. Tag `v<version>` on that `main` merge commit, then push `main` and the tag.
53
59
 
54
- Step 4 is the only way commits are meant to reach `main`. A feature or fix PR targeting `main` breaks that: it puts work on the release branch that `development` does not have, so the next release is cut without it. Such PRs belong on `development` (see the pull request target rule in `AGENTS.md`).
60
+ For a full release, step 4 is the only way commits are meant to reach `main`. A feature or fix
61
+ PR targeting `main` breaks that: it puts work on the release branch that `development` does not
62
+ have, so the next release is cut without it. Such PRs belong on `development` (see the pull
63
+ request target rule in `AGENTS.md`).
64
+
65
+ ### Scoped patch (hotfix — ships only specific fixes on top of the last tag)
66
+
67
+ Use this when the patch must ship **only** a named set of fixes on top of the last released tag,
68
+ **not** the unreleased work sitting on `development` (e.g. a patch over `v1.6.7` that must carry
69
+ just a winget/Windows hotfix while ACP features stay unreleased). The giveaway is wording like
70
+ "scoped patch off `v<prev>`" or "hotfix" — if `development` is far ahead, a `development → main`
71
+ merge would drag all of it out under a patch tag, which is wrong.
72
+
73
+ 1. Branch `release/v<version>` off the **last released tag** (`v<prev>`), not `development`.
74
+ 2. Cherry-pick (or commit) only the in-scope fixes, then the version bump + changelog, and commit
75
+ (e.g. `Release v<version>`).
76
+ 3. Merge `release/v<version>` back into `development` so the bump and changelog carry forward.
77
+ 4. Merge `release/v<version>` **directly into `main`** (commit message: `Release v<version>`) —
78
+ **never `development → main` for a scoped patch**, since that would pull `development`'s
79
+ unreleased work onto `main`.
80
+ 5. Tag `v<version>` on that `main` merge commit, then push `main` and the tag.
81
+
82
+ Before tagging a scoped patch, audit the scope: `git log --oneline v<prev>^{commit}..main` and
83
+ `git diff --stat v<prev>^{commit}..main` must show only the intended fixes and the release
84
+ metadata files (`Cargo.toml`, `Cargo.lock`, `CHANGELOG.md`), nothing from `development`.
55
85
 
56
86
  Pushing the `v*.*.*` tag is what fires every release workflow, so create and push it only after the merge into `main` has landed. Do not commit, tag, or push until the user explicitly asks — confirm the version and that they want the release to go out first.
57
87
 
88
+ Decide up front which flow you are in and confirm the exact scope with the user. If in doubt about
89
+ whether a release ships everything on `development` or only a scoped set of fixes, ask — the two
90
+ flows start from different base commits and are not interchangeable.
91
+
58
92
  ## Typical files to inspect
59
93
 
60
94
  - `Cargo.toml`
@@ -79,10 +113,12 @@ Pushing the `v*.*.*` tag is what fires every release workflow, so create and pus
79
113
  - `npm/sigit/package.json` is left at `0.0.0-dev` unless the packaging flow itself changed.
80
114
  - `pypi/pyproject.toml` still uses dynamic versioning unless there is a deliberate packaging change.
81
115
  - Release workflows still derive their version from the tag as expected.
82
- - Git flow followed: bump committed on `release/v<version>`, merged back to `development`, then `development` merged into `main`, with `v<version>` tagged on the `main` merge commit.
116
+ - Correct git flow for the release type followed, with `v<version>` tagged on the `main` merge commit either way:
117
+ - **Full release:** bump committed on `release/v<version>` (off `development`), merged back to `development`, then `development` merged into `main`.
118
+ - **Scoped patch:** `release/v<version>` branched off the last tag `v<prev>`, in-scope fixes + bump committed, merged back to `development`, then `release/v<version>` merged **directly into `main`** (never `development → main`), and the scope audited (`git log --oneline v<prev>^{commit}..main` / `git diff --stat v<prev>^{commit}..main`) to show only the intended fixes plus release metadata.
83
119
  - Release notes or changelog entries match the actual changes.
84
120
  - CI-equivalent local checks pass for the relevant platform or target.
85
121
  - Package names, install commands, and branding stay consistent across npm, PyPI, crates.io, Homebrew, NuGet, Scoop, winget, AUR, and the `.deb`/`.rpm` packages.
86
122
  - Every release asset (including the new `.deb`/`.rpm`/raw binaries consumed by Scoop, winget, and AUR) has a matching `.sha256` sidecar.
87
123
  - Any known release limitations are called out explicitly, including a dispatch failure in `release-scoop`/`release-winget`/`release-aur` (non-fatal — logged as a warning by `release-github.yml`) versus a hard failure in a directly tag-triggered workflow like `release-nuget` or `release-mcp-registry`.
88
- - The `WINGET_TOKEN` secret is a classic PAT with `public_repo` scope (not fine-grained), so wingetcreate can fork `microsoft/winget-pkgs`. If a winget release fails on the fork step, verify the token type before re-dispatching.
124
+ - The winget channel uses two classic PATs (not fine-grained): `WINGET_ISSUE_TOKEN` (`public_repo`) for the Package Request issue and `WINGET_PR_TOKEN` (`public_repo` + `read:org` + `read:discussion`) for the manifest PR, so wingetcreate can fork `microsoft/winget-pkgs` and `gh pr edit` can set the PR body. If a winget release fails on the fork step, verify the token type; if it fails on the `gh pr edit` step, verify `WINGET_PR_TOKEN`'s scopes before re-dispatching.
@@ -38,12 +38,18 @@ Use this skill when preparing a release for this repository.
38
38
  - The GitHub release body is generated, not hand-written: `release-github.yml`'s release job checks out the tag, awk-extracts the `## <version>` section from `CHANGELOG.md` (a missing section is a hard failure), appends a full-changelog link and a `sigit.si/code` footnote, and publishes it via `body_path: release_notes.md`. The only file a release-prep change needs to touch is `CHANGELOG.md` — never edit release notes in the GitHub UI, because the next tag push would overwrite them.
39
39
  - npm and NuGet publish via OIDC trusted publishing — neither has a stored token in the repo. On npm this is configured per package (each `@getsigit/*` name has a trusted publisher pointing at `release-npm.yml`), so a new package needs the same setup before its first publish will work. OIDC requires npm >= 11.5.1 and Node >= 22.14.0.
40
40
  - The NuGet package (`SiGit.Code`, `dotnet tool install --global SiGit.Code`) bundles all six platform binaries under `native/<os>-<arch>/` behind a managed shim, rather than shipping one package per platform like npm/PyPI. Watch the nuget.org 250 MB package-size limit if it ever grows.
41
- - The winget channel (`release-winget.yml`) needs a `WINGET_TOKEN` secret that is a **classic PAT** with `public_repo` scope — not a fine-grained PAT. wingetcreate forks `microsoft/winget-pkgs` on first use and submits a PR from that fork; a fine-grained PAT scoped to `getsigit/sigit` cannot fork a repo outside its scope and every release fails with a 403/404 on the fork step. The token is passed as `WINGET_CREATE_GITHUB_TOKEN` (not `--token`) so it doesn't leak into logs. `wingetcreate update` only works on a package that already exists in `microsoft/winget-pkgs`, so the workflow checks `manifests/g/getSigit/siGitCode` first and falls back to `wingetcreate submit` with `packaging/winget/*.yaml.in` for the first release; every later release takes the `update` path. If a winget release fails with a fork/permission error, check the `WINGET_TOKEN` secret type before re-dispatching.
41
+ - The winget channel (`release-winget.yml`) files a Package Request issue first and then opens the manifest PR that resolves it, authored by **two separate classic PATs** (not fine-grained) — `WINGET_ISSUE_TOKEN` for the issue and `WINGET_PR_TOKEN` for the PR, replacing the old single `WINGET_TOKEN`. `WINGET_ISSUE_TOKEN` needs `public_repo`. `WINGET_PR_TOKEN` needs `public_repo` **plus `read:org` and `read:discussion`**: wingetcreate forks `microsoft/winget-pkgs` and opens the PR (fork needs `public_repo`; a fine-grained PAT scoped to `getsigit/sigit` can't fork an outside repo and 403/404s), and the "Set the winget PR body" step then runs `gh pr edit`, whose GraphQL query resolves org/team and discussion fields and fails with a scope error on a `public_repo`-only token (CI run 38262301933). The PR token is passed as `WINGET_CREATE_GITHUB_TOKEN` (not `--token`) so it doesn't leak into logs. `wingetcreate update` only works on a package that already exists in `microsoft/winget-pkgs`, so the workflow checks `manifests/g/getSigit/siGitCode` first and falls back to `wingetcreate submit` with `packaging/winget/*.yaml.in` for the first release; every later release takes the `update` path. If a winget release fails on the fork step, check the token type; if it fails on the `gh pr edit` step with a scope error, check `WINGET_PR_TOKEN`'s scopes before re-dispatching.
42
42
  - The baked-in official MCP server is separately listed in the public MCP Registry as `si.sigit/sigit` (`release-mcp-registry.yml`, driven by `server.json` at the repo root). It's a remote Streamable-HTTP listing, so it's verified by a DNS TXT record on the `sigit.si` domain rather than the GitHub-OIDC scheme used for package listings.
43
43
 
44
44
  ## Git release flow
45
45
 
46
- Releases are cut from `development` and shipped on `main`. The published tags (`v1.2.0`, `v1.2.1`) point at the `main`-side merge commit, never at a release branch or at `development`. Follow this order:
46
+ Releases are shipped on `main`, and the published tags (`v1.2.0`, `v1.2.1`) point at the
47
+ `main`-side merge commit, never at a release branch or at `development`. There are two flows,
48
+ and picking the wrong one ships the wrong set of commits. Decide first which this is.
49
+
50
+ ### Full release (minor/major — ships everything on `development`)
51
+
52
+ Cut from `development` when the release is meant to carry all the work that has accumulated there.
47
53
 
48
54
  1. Branch `release/v<version>` off `development`.
49
55
  2. Apply the version bump and changelog edits, then commit (e.g. `Release v<version>`).
@@ -51,10 +57,38 @@ Releases are cut from `development` and shipped on `main`. The published tags (`
51
57
  4. Merge `development` into `main` (commit message: `Merge development into main for v<version> release`).
52
58
  5. Tag `v<version>` on that `main` merge commit, then push `main` and the tag.
53
59
 
54
- Step 4 is the only way commits are meant to reach `main`. A feature or fix PR targeting `main` breaks that: it puts work on the release branch that `development` does not have, so the next release is cut without it. Such PRs belong on `development` (see the pull request target rule in `AGENTS.md`).
60
+ For a full release, step 4 is the only way commits are meant to reach `main`. A feature or fix
61
+ PR targeting `main` breaks that: it puts work on the release branch that `development` does not
62
+ have, so the next release is cut without it. Such PRs belong on `development` (see the pull
63
+ request target rule in `AGENTS.md`).
64
+
65
+ ### Scoped patch (hotfix — ships only specific fixes on top of the last tag)
66
+
67
+ Use this when the patch must ship **only** a named set of fixes on top of the last released tag,
68
+ **not** the unreleased work sitting on `development` (e.g. a patch over `v1.6.7` that must carry
69
+ just a winget/Windows hotfix while ACP features stay unreleased). The giveaway is wording like
70
+ "scoped patch off `v<prev>`" or "hotfix" — if `development` is far ahead, a `development → main`
71
+ merge would drag all of it out under a patch tag, which is wrong.
72
+
73
+ 1. Branch `release/v<version>` off the **last released tag** (`v<prev>`), not `development`.
74
+ 2. Cherry-pick (or commit) only the in-scope fixes, then the version bump + changelog, and commit
75
+ (e.g. `Release v<version>`).
76
+ 3. Merge `release/v<version>` back into `development` so the bump and changelog carry forward.
77
+ 4. Merge `release/v<version>` **directly into `main`** (commit message: `Release v<version>`) —
78
+ **never `development → main` for a scoped patch**, since that would pull `development`'s
79
+ unreleased work onto `main`.
80
+ 5. Tag `v<version>` on that `main` merge commit, then push `main` and the tag.
81
+
82
+ Before tagging a scoped patch, audit the scope: `git log --oneline v<prev>^{commit}..main` and
83
+ `git diff --stat v<prev>^{commit}..main` must show only the intended fixes and the release
84
+ metadata files (`Cargo.toml`, `Cargo.lock`, `CHANGELOG.md`), nothing from `development`.
55
85
 
56
86
  Pushing the `v*.*.*` tag is what fires every release workflow, so create and push it only after the merge into `main` has landed. Do not commit, tag, or push until the user explicitly asks — confirm the version and that they want the release to go out first.
57
87
 
88
+ Decide up front which flow you are in and confirm the exact scope with the user. If in doubt about
89
+ whether a release ships everything on `development` or only a scoped set of fixes, ask — the two
90
+ flows start from different base commits and are not interchangeable.
91
+
58
92
  ## Typical files to inspect
59
93
 
60
94
  - `Cargo.toml`
@@ -79,10 +113,12 @@ Pushing the `v*.*.*` tag is what fires every release workflow, so create and pus
79
113
  - `npm/sigit/package.json` is left at `0.0.0-dev` unless the packaging flow itself changed.
80
114
  - `pypi/pyproject.toml` still uses dynamic versioning unless there is a deliberate packaging change.
81
115
  - Release workflows still derive their version from the tag as expected.
82
- - Git flow followed: bump committed on `release/v<version>`, merged back to `development`, then `development` merged into `main`, with `v<version>` tagged on the `main` merge commit.
116
+ - Correct git flow for the release type followed, with `v<version>` tagged on the `main` merge commit either way:
117
+ - **Full release:** bump committed on `release/v<version>` (off `development`), merged back to `development`, then `development` merged into `main`.
118
+ - **Scoped patch:** `release/v<version>` branched off the last tag `v<prev>`, in-scope fixes + bump committed, merged back to `development`, then `release/v<version>` merged **directly into `main`** (never `development → main`), and the scope audited (`git log --oneline v<prev>^{commit}..main` / `git diff --stat v<prev>^{commit}..main`) to show only the intended fixes plus release metadata.
83
119
  - Release notes or changelog entries match the actual changes.
84
120
  - CI-equivalent local checks pass for the relevant platform or target.
85
121
  - Package names, install commands, and branding stay consistent across npm, PyPI, crates.io, Homebrew, NuGet, Scoop, winget, AUR, and the `.deb`/`.rpm` packages.
86
122
  - Every release asset (including the new `.deb`/`.rpm`/raw binaries consumed by Scoop, winget, and AUR) has a matching `.sha256` sidecar.
87
123
  - Any known release limitations are called out explicitly, including a dispatch failure in `release-scoop`/`release-winget`/`release-aur` (non-fatal — logged as a warning by `release-github.yml`) versus a hard failure in a directly tag-triggered workflow like `release-nuget` or `release-mcp-registry`.
88
- - The `WINGET_TOKEN` secret is a classic PAT with `public_repo` scope (not fine-grained), so wingetcreate can fork `microsoft/winget-pkgs`. If a winget release fails on the fork step, verify the token type before re-dispatching.
124
+ - The winget channel uses two classic PATs (not fine-grained): `WINGET_ISSUE_TOKEN` (`public_repo`) for the Package Request issue and `WINGET_PR_TOKEN` (`public_repo` + `read:org` + `read:discussion`) for the manifest PR, so wingetcreate can fork `microsoft/winget-pkgs` and `gh pr edit` can set the PR body. If a winget release fails on the fork step, verify the token type; if it fails on the `gh pr edit` step, verify `WINGET_PR_TOKEN`'s scopes before re-dispatching.
@@ -132,8 +132,11 @@ jobs:
132
132
  # The issue and the PR are authored by two separate PATs, configured as
133
133
  # the repo secrets WINGET_ISSUE_TOKEN (the Package Request issue) and
134
134
  # WINGET_PR_TOKEN (the manifest PR). Both need public_repo scope on
135
- # microsoft/winget-pkgs (fork + open issues/PRs). WINGET_PR_TOKEN
136
- # replaces the old single WINGET_TOKEN.
135
+ # microsoft/winget-pkgs (fork + open issues/PRs). WINGET_PR_TOKEN also
136
+ # needs read:org and read:discussion: the "Set the winget PR body" step
137
+ # below calls `gh pr edit`, whose GraphQL query resolves org/team and
138
+ # discussion fields and fails with a scope error without them.
139
+ # WINGET_PR_TOKEN replaces the old single WINGET_TOKEN.
137
140
  - name: File the winget Package Request issue
138
141
  id: issue
139
142
  shell: bash
@@ -182,6 +185,11 @@ jobs:
182
185
  echo "issue_url=${ISSUE_URL}" >> "$GITHUB_OUTPUT"
183
186
  echo "issue_number=${ISSUE_URL##*/}" >> "$GITHUB_OUTPUT"
184
187
 
188
+ - name: Sebat dulu ...
189
+ if: steps.issue.outputs.issue_number != ''
190
+ shell: bash
191
+ run: sleep $(( RANDOM % 61 + 150 ))
192
+
185
193
  - name: Submit new package
186
194
  id: submit_new
187
195
  if: steps.exists.outputs.found == 'false'
@@ -194,7 +202,7 @@ jobs:
194
202
  WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_PR_TOKEN }}
195
203
  run: |
196
204
  if (-not $env:WINGET_CREATE_GITHUB_TOKEN) {
197
- Write-Error "WINGET_PR_TOKEN is not set. It needs a PAT with public_repo scope so wingetcreate can fork microsoft/winget-pkgs and open the manifest PR."
205
+ Write-Error "WINGET_PR_TOKEN is not set. It needs a PAT with public_repo, read:org, and read:discussion scopes so wingetcreate can fork microsoft/winget-pkgs and open the manifest PR, and gh can edit the PR body."
198
206
  }
199
207
 
200
208
  $version = "${{ steps.release.outputs.version }}"
@@ -228,7 +236,7 @@ jobs:
228
236
  WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_PR_TOKEN }}
229
237
  run: |
230
238
  if (-not $env:WINGET_CREATE_GITHUB_TOKEN) {
231
- Write-Error "WINGET_PR_TOKEN is not set. It needs a PAT with public_repo scope so wingetcreate can fork microsoft/winget-pkgs and open the manifest PR."
239
+ Write-Error "WINGET_PR_TOKEN is not set. It needs a PAT with public_repo, read:org, and read:discussion scopes so wingetcreate can fork microsoft/winget-pkgs and open the manifest PR, and gh can edit the PR body."
232
240
  }
233
241
 
234
242
  $tag = "${{ steps.release.outputs.tag }}"
@@ -502,8 +502,13 @@ Three of these need credentials or a one-time manual step before they work:
502
502
 
503
503
  - Scoop needs a `getsigit/scoop-bucket` repo and a `SCOOP_BUCKET_TOKEN` secret, mirroring the
504
504
  Homebrew tap setup.
505
- - winget needs a `WINGET_TOKEN` (PAT with `public_repo` scope) so `wingetcreate` can fork
506
- `microsoft/winget-pkgs` and open the manifest PR. It is passed as
505
+ - winget files a Package Request issue first and then opens the manifest PR that resolves it,
506
+ authored by two separate classic PATs (replacing the old single `WINGET_TOKEN`):
507
+ `WINGET_ISSUE_TOKEN` (`public_repo`) for the issue and `WINGET_PR_TOKEN` for the PR.
508
+ `WINGET_PR_TOKEN` needs `public_repo` plus `read:org` and `read:discussion`: `public_repo`
509
+ lets `wingetcreate` fork `microsoft/winget-pkgs` and open the PR, and the later `gh pr edit`
510
+ that sets the PR body runs a GraphQL query resolving org/team and discussion fields that
511
+ fails with a scope error on a `public_repo`-only token. The PR token is passed as
507
512
  `WINGET_CREATE_GITHUB_TOKEN` rather than `--token`, which wingetcreate warns can leak the
508
513
  token into logs. `wingetcreate update` only works on a package that already exists in
509
514
  `microsoft/winget-pkgs`, so the workflow checks the `manifests/g/getSigit/siGitCode` path
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.9
4
+
5
+ ### Fixed
6
+
7
+ - **Doubled-bar DSML markers survive inline tool-call recovery.** Some models
8
+ write `||name||args||` (and single-bar `|name|args|`) instead of the
9
+ underscore-form `__name__args__` that `inline_tool_calls` was matching.
10
+ These were being dropped from the recovered reply and from history, so the
11
+ model thought its call had run. The scanner now accepts both forms, and
12
+ a buffer that contains no doubled bar skips the regex fold entirely
13
+ instead of allocating an empty `String`.
14
+
15
+ ### Changed
16
+
17
+ - **The release workflow is split into full-release and scoped-patch flows.**
18
+ `sigit-code-release` now leads with a scoped-patch path for one or two
19
+ commits on top of the last tag (no new release skill, no ACP advert,
20
+ no `## $version` requirement), then a full-release path for normal
21
+ development → `main` releases. Winget CI also gets a blank line between
22
+ the issue body and the manifest PR body so the rendered issue no longer
23
+ runs the two together.
24
+
3
25
  ## 1.6.8
4
26
 
5
27
  ### Fixed
@@ -502,8 +502,13 @@ Three of these need credentials or a one-time manual step before they work:
502
502
 
503
503
  - Scoop needs a `getsigit/scoop-bucket` repo and a `SCOOP_BUCKET_TOKEN` secret, mirroring the
504
504
  Homebrew tap setup.
505
- - winget needs a `WINGET_TOKEN` (PAT with `public_repo` scope) so `wingetcreate` can fork
506
- `microsoft/winget-pkgs` and open the manifest PR. It is passed as
505
+ - winget files a Package Request issue first and then opens the manifest PR that resolves it,
506
+ authored by two separate classic PATs (replacing the old single `WINGET_TOKEN`):
507
+ `WINGET_ISSUE_TOKEN` (`public_repo`) for the issue and `WINGET_PR_TOKEN` for the PR.
508
+ `WINGET_PR_TOKEN` needs `public_repo` plus `read:org` and `read:discussion`: `public_repo`
509
+ lets `wingetcreate` fork `microsoft/winget-pkgs` and open the PR, and the later `gh pr edit`
510
+ that sets the PR body runs a GraphQL query resolving org/team and discussion fields that
511
+ fails with a scope error on a `public_repo`-only token. The PR token is passed as
507
512
  `WINGET_CREATE_GITHUB_TOKEN` rather than `--token`, which wingetcreate warns can leak the
508
513
  token into logs. `wingetcreate update` only works on a package that already exists in
509
514
  `microsoft/winget-pkgs`, so the workflow checks the `manifests/g/getSigit/siGitCode` path
@@ -5901,7 +5901,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
5901
5901
 
5902
5902
  [[package]]
5903
5903
  name = "sigit"
5904
- version = "1.6.8"
5904
+ version = "1.6.9"
5905
5905
  dependencies = [
5906
5906
  "agent-client-protocol",
5907
5907
  "anyhow",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sigit"
3
- version = "1.6.8"
3
+ version = "1.6.9"
4
4
  edition = "2024"
5
5
  description = "siGit Code — ACP-compatible AI coding agent. Sí, git."
6
6
  documentation = "https://github.com/getsigit/sigit"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sigit-code
3
- Version: 1.6.8
3
+ Version: 1.6.9
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -33,6 +33,10 @@
33
33
  //! malformed when what follows the marker looks like the start of a call (see
34
34
  //! [`looks_like_xml_call`] and [`looks_like_k3_call`]); otherwise it stays
35
35
  //! text, as it always did.
36
+ //!
37
+ //! One spelling difference is folded away before matching: DSML markers are
38
+ //! built from fullwidth bars, and some stacks hand them back with each bar
39
+ //! doubled (`<||DSML||invoke …>`). See [`fold_dsml_bars`].
36
40
 
37
41
  use std::collections::HashMap;
38
42
 
@@ -53,6 +57,13 @@ const K3_THINK_OPEN: &str = "<|open|>think<|sep|>";
53
57
  const K3_THINK_CLOSE: &str = "<|close|>think<|sep|>";
54
58
  const K3_SEP: &str = "<|sep|>";
55
59
 
60
+ /// `|` (U+FF5C), the bar every DSML marker above is built from.
61
+ const FULLWIDTH_BAR: char = '|';
62
+ /// Two of them — the spelling [`fold_dsml_bars`] rewrites into one.
63
+ const DOUBLED_BAR: &str = "||";
64
+ /// The name those markers wrap, and the only place [`fold_dsml_bars`] folds.
65
+ const DSML_TAG_NAME: &str = "DSML";
66
+
56
67
  const DSML_TOOL_CALLS_OPEN: &str = "<|DSML|tool_calls>";
57
68
  const DSML_TOOL_CALLS_CLOSE: &str = "<|DSML|/tool_calls>";
58
69
  const DSML_INVOKE_OPEN: &str = "<|DSML|invoke";
@@ -447,6 +458,16 @@ impl<'a> StreamScanner<'a> {
447
458
  /// be empty if the chunk was absorbed into a tag that hasn't closed yet.
448
459
  pub fn push(&mut self, chunk: &str) -> Vec<ScanEvent> {
449
460
  self.pending.push_str(chunk);
461
+ // The fold runs on the whole buffer, not the chunk: a doubled bar split
462
+ // across two deltas is only visible once they are joined, and a marker
463
+ // is never emitted before then because a partial prefix is held back.
464
+ // Both the single- and doubled-bar spellings are partial prefixes
465
+ // (START_MARKERS is single-bar), so a doubled-bar prefix is flushed as
466
+ // text on this push; the fold then rewrites the buffer and the next
467
+ // push re-runs `drain` against canonical markers.
468
+ if let Some(folded) = fold_dsml_bars(&self.pending) {
469
+ self.pending = folded;
470
+ }
450
471
  self.drain()
451
472
  }
452
473
 
@@ -651,6 +672,71 @@ fn looks_like_dsml_call(after_marker: &str) -> bool {
651
672
  rest.is_empty() || rest.starts_with(DSML_INVOKE_OPEN)
652
673
  }
653
674
 
675
+ /// Every DSML marker above carries exactly one fullwidth bar on each side of
676
+ /// `DSML`. Some serving stacks emit each bar twice (`<||DSML||invoke …>`),
677
+ /// which no marker matches, so the block falls through as text and renders raw
678
+ /// in the editor.
679
+ ///
680
+ /// Folding the run on both sides of `DSML` down to a single bar, rather than
681
+ /// listing a doubled-bar constant next to each one, keeps the marker set at six
682
+ /// and covers a run of any length. A bar run elsewhere is never touched, so a
683
+ /// fullwidth bar used as prose or as a table rule passes through unchanged. The
684
+ /// fold is not conditional on the block parsing, so a doubled-bar marker that
685
+ /// turns out not to be a call comes back with single bars — markup either way.
686
+ ///
687
+ /// Returns `None` when there is nothing to fold — in particular for any buffer
688
+ /// without a doubled bar, which is the common case and costs no allocation.
689
+ fn fold_dsml_bars(text: &str) -> Option<String> {
690
+ if !text.contains(DSML_TAG_NAME) {
691
+ return None;
692
+ }
693
+ // A DSML tag with only single bars is canonical; only the doubled-bar
694
+ // spelling needs rewriting, so the buffer-walking allocation can be
695
+ // skipped when the only defect isn't present.
696
+ if !text.contains(DOUBLED_BAR) {
697
+ return None;
698
+ }
699
+ let mut folded = String::with_capacity(text.len());
700
+ let mut changed = false;
701
+ let mut rest = text;
702
+ while let Some(idx) = rest.find(DSML_TAG_NAME) {
703
+ let (before, from_name) = rest.split_at(idx);
704
+ // The run of bars closing the tag being opened, i.e. `<||` in
705
+ // `<||DSML||invoke`. Counted in bars, not bytes: a lone fullwidth bar
706
+ // is three bytes, so a byte count would read as a run of its own.
707
+ let opening_run = bar_run_len(before, false);
708
+ folded.push_str(&before[..before.len() - opening_run * FULLWIDTH_BAR.len_utf8()]);
709
+ if opening_run > 0 {
710
+ changed |= opening_run > 1;
711
+ folded.push(FULLWIDTH_BAR);
712
+ }
713
+ folded.push_str(DSML_TAG_NAME);
714
+ let after = &from_name[DSML_TAG_NAME.len()..];
715
+ // The run opening the tag being closed, i.e. `||>` in
716
+ // `<|DSML||/invoke>`.
717
+ let closing_run = bar_run_len(after, true);
718
+ if closing_run > 0 {
719
+ changed |= closing_run > 1;
720
+ folded.push(FULLWIDTH_BAR);
721
+ }
722
+ rest = &after[closing_run * FULLWIDTH_BAR.len_utf8()..];
723
+ }
724
+ folded.push_str(rest);
725
+ changed.then_some(folded)
726
+ }
727
+
728
+ /// Number of fullwidth bars in the run at the end of `text`, or at its start
729
+ /// when `leading` is set. The caller passes the slice anchored to `DSML`, so
730
+ /// the run being counted always sits at the boundary, never in the middle.
731
+ fn bar_run_len(text: &str, leading: bool) -> usize {
732
+ let bytes = if leading {
733
+ text.len() - text.trim_start_matches(FULLWIDTH_BAR).len()
734
+ } else {
735
+ text.len() - text.trim_end_matches(FULLWIDTH_BAR).len()
736
+ };
737
+ bytes / FULLWIDTH_BAR.len_utf8()
738
+ }
739
+
654
740
  fn scan_complete_text(text: &str, tools: &[ToolSpec]) -> Vec<ScanEvent> {
655
741
  let mut scanner = StreamScanner::new(tools);
656
742
  let mut events = scanner.push(text);
@@ -1492,4 +1578,95 @@ mod tests {
1492
1578
  ))
1493
1579
  );
1494
1580
  }
1581
+
1582
+ #[test]
1583
+ fn fold_dsml_bars_collapses_runs_glued_to_dsml_only() {
1584
+ assert_eq!(
1585
+ fold_dsml_bars("<||DSML||invoke name=\"run_command\">"),
1586
+ Some("<|DSML|invoke name=\"run_command\">".to_string())
1587
+ );
1588
+ assert_eq!(
1589
+ fold_dsml_bars("<||||DSML|/tool_calls>"),
1590
+ Some("<|DSML|/tool_calls>".to_string())
1591
+ );
1592
+ // Only the bar run adjacent to DSML folds; a doubled bar further along
1593
+ // in the tag name or body is not part of the marker grammar.
1594
+ assert_eq!(
1595
+ fold_dsml_bars("<|DSML||param||eter"),
1596
+ Some("<|DSML|param||eter".to_string())
1597
+ );
1598
+ // A bar run that isn't a marker, and single-bar markup, are left alone.
1599
+ assert_eq!(fold_dsml_bars("a || b <|DSML|invoke>"), None);
1600
+ assert_eq!(fold_dsml_bars("<|DSML|invoke>"), None);
1601
+ assert_eq!(fold_dsml_bars("plain text"), None);
1602
+ }
1603
+
1604
+ #[test]
1605
+ fn a_doubled_bar_dsml_call_is_recovered() {
1606
+ let tools = vec![run_command_spec()];
1607
+ let Extracted {
1608
+ text,
1609
+ calls,
1610
+ malformed,
1611
+ } = extract(
1612
+ "before <||DSML||tool_calls><||DSML||invoke name=\"run_command\">\
1613
+ <||DSML||parameter name=\"command\" string=\"true\">echo hi\
1614
+ <||DSML||/parameter><||DSML||/invoke><||DSML||/tool_calls> after",
1615
+ &tools,
1616
+ );
1617
+
1618
+ assert_eq!(text, "before after");
1619
+ assert_eq!(malformed, 0);
1620
+ assert_eq!(calls.len(), 1);
1621
+ assert_eq!(calls[0].name, "run_command");
1622
+ let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1623
+ assert_eq!(args["command"], "echo hi");
1624
+ }
1625
+
1626
+ #[test]
1627
+ fn a_doubled_bar_split_across_pushes_is_recovered() {
1628
+ let tools = vec![run_command_spec()];
1629
+ let mut scanner = StreamScanner::new(&tools);
1630
+ let mut text = String::new();
1631
+ let mut calls = Vec::new();
1632
+
1633
+ for chunk in [
1634
+ "pre <|",
1635
+ "|DSML||tool_calls><||DSML||invoke name=\"run_com",
1636
+ "mand\"><||DSML||parameter name=\"command\" string=\"true\">echo hi\
1637
+ <||DSML||/parameter><||DSML||/invoke><||DSML||/tool_calls>",
1638
+ " post",
1639
+ ] {
1640
+ for event in scanner.push(chunk) {
1641
+ match event {
1642
+ ScanEvent::Text(t) => text.push_str(&t),
1643
+ ScanEvent::ToolCall(call) => calls.push(call),
1644
+ ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
1645
+ }
1646
+ }
1647
+ }
1648
+ if let Some(ScanEvent::Text(rest)) = scanner.finish() {
1649
+ text.push_str(&rest);
1650
+ }
1651
+
1652
+ assert_eq!(text, "pre post");
1653
+ assert_eq!(calls.len(), 1);
1654
+ assert_eq!(calls[0].name, "run_command");
1655
+ let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1656
+ assert_eq!(args["command"], "echo hi");
1657
+ }
1658
+
1659
+ #[test]
1660
+ fn doubled_bar_markup_outside_a_call_returns_folded_as_text() {
1661
+ let tools = vec![run_command_spec()];
1662
+ let Extracted {
1663
+ text,
1664
+ calls,
1665
+ malformed,
1666
+ } = extract("the tag <||DSML||done> is not a call", &tools);
1667
+
1668
+ assert_eq!(text, "the tag <|DSML|done> is not a call");
1669
+ assert!(calls.is_empty());
1670
+ assert_eq!(malformed, 0);
1671
+ }
1495
1672
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes