Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"name": "work-system",
"source": "./plugins/work-system",
"description": "Generic task and worktree workflow system for Claude Code. Manage tasks as markdown files, run them in isolated git worktrees with a choice of worker agent (Claude, codex, or grok), and track progress across the define/kickoff/continue/status/close lifecycle.",
"version": "1.9.4"
"version": "1.10.0"
},
{
"name": "pr-flow",
Expand Down
2 changes: 1 addition & 1 deletion .claude/knowledge/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
- `features/herdr-close-automation.md` — `/close` in herdr: cwd-tab teardown, plugin SessionEnd hook, the one TUI-exit primitive, detached self-exit onto idle
- `features/herdr-tab-glyphs.md` — Task-state glyphs (`○ ● ◇ ◆ ✓`) + main-root `◉` on herdr tab labels: `states` mode in the self-contained renderer, sync-vs-`--cached` PR refresh per caller, exact-cwd rename rules, soft pr-flow shim
- `features/kickoff-agent-selection.md` — `/kickoff` worker choice: single committed per-repo default (no global/fallback/ranking) else picker; `agent-registry.sh` as SoT; bounded model-aware grok probe (inconclusive→trust-auth); non-claude "document, don't fake" degradation; announce-not-prompt for external defaults
- `features/task-archiving-on-close.md` — `/close` archives (not deletes) the task file; adaptive commit + ff-push to main
- `features/task-archiving-on-close.md` — `/close` archives (not deletes) the task file; adaptive commit + ff-push to main; per-repo `.claude/work-system-close-autocommit` opt-in skips the ask
- `features/swarm-backend-adapter.md` — 0.6.0 read+web posture: OS secret-jail (denylist, worktree-aware, git-config-safe), per-voice fail-closed degrade, `jail` verb, prompt egress guard + residual risks; plus verified codex/grok CLI facts (schema JSON, effort mapping, model-aware readiness)
- `features/swarm-review-pipeline.md` — `/swarm:review` pipeline: skill↔Workflow wiring, family-consensus, 0.5.0 lens clusters + design-kind verify, `--fix`/`--loop` (deterministic close-out via `loop-closeout.py`), `--pr` publish via deterministic `pr-post.py`

Expand Down
122 changes: 118 additions & 4 deletions .claude/knowledge/features/task-archiving-on-close.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
---
title: "Task Archiving on /close"
createdAt: 2026-06-29
updatedAt: 2026-07-12
updatedAt: 2026-07-24
createdFrom: "PR #19"
updatedFrom: "session: 2026-07-12"
pluginVersion: 1.8.2
updatedFrom: "session: 2026-07-24"
pluginVersion: 1.10.0
prime: false
reindexedAt: 2026-07-12
---
Expand Down Expand Up @@ -43,7 +43,9 @@ logic belongs in a tested script, not SKILL.md prose). See also [skill-compositi
- **All git-stateful work is in `commit-push`, gated by `/close`.** Honoring the
never-commit-without-approval rule, `archive-task.sh archive` only moves the file
+ appends the index. When `committable=yes`, `/close` asks **once** ("commit and
push?") and then delegates the entire stage→commit→push to `archive-task.sh
push?") — **unless the repo carries the committed per-repo autocommit opt-in, which
replaces that per-close ask with a durable authorization (see the 1.10.0 section
below)** — and then delegates the entire stage→commit→push to `archive-task.sh
commit-push` — kept out of SKILL.md prose so the multi-step git logic can't drift
(the project's prose-drift lesson). Two correctness guards earned in review:
it commits with an explicit **pathspec** (`git commit -- <archive> <index>
Expand Down Expand Up @@ -82,3 +84,115 @@ logic belongs in a tested script, not SKILL.md prose). See also [skill-compositi
non-recursive `tasks/*.md`, which already excludes `tasks/archive/`. The "never
persistent `cd`" footgun the helper's explicit paths avoid is a rule — see
`.claude/rules/cwd-safety.md`.

## Per-repo autocommit opt-in (1.10.0)

- **The commit+push ask is skippable, per repo.** In a repo where
archiving-to-main is the norm (dotfiles, this repo), the always-yes y/n prompt
is pure friction — observed repeatedly across close runs. A committed
`.claude/work-system-close-autocommit` flag (content `yes`/`true`) routes
`/close` straight to `commit-push`, no `AskUserQuestion`, still reporting the
result exactly as the manual path does. Mirrors the `.claude/work-system-agent`
default precedent: a committed per-repo file, not a settings-plugin entry
(Phase-1, not yet consumed elsewhere) and not local git config (wouldn't travel
with the repo). `archive-task.sh` grew an `autocommit get|set|unset` subcommand
as the single source of truth for the flag, mirroring the script-owns-the-logic
split the rest of this feature already follows.
- **The global-rule framing.** The user's standing rule is "ask before pushing to
the default branch." This flag is the durable per-repo **authorization** for
exactly ONE narrow action — the archive-metadata commit+push in step 10 — not a
general loosening of that rule. What makes it safe to grant is that
`commit-push`'s existing guards (above) already bound the blast radius: an
exact pathspec commit (never a blanket `tasks/` add), fast-forward only, never
a force-push, and a hard refusal (`unpushed-history`) when `main` carries other
unpushed commits. The opt-in only removes the prompt; it does not weaken any of
those guards.
- **Off by default, per-repo only.** No global default and no shipped fallback —
the same shape as the agent-default. A repo that wants this opts in explicitly
by committing the flag file; a global toggle was explicitly deferred as a
separate follow-up, not built speculatively.
- **Presence is not authorization; the committed value is (swarm-review).** The
first cut honored the flag if the file merely *existed* — too weak for a flag
whose job is waiving an approval gate for a push to the default branch: any
tool, or a prompt-injected worker agent in a task worktree, can write a file
with nobody approving a push. So `get` reads the value from the **committed
object** (`git show HEAD:<rel>`), never from the working tree. That one call is
both the tracked-check and the value read, and it is immune to the working-tree
tricks that fool a diff-based guard (`git update-index --assume-unchanged` /
`--skip-worktree` make `ls-files`/`diff --quiet` report a dirty file as clean —
round 2 of the review reproduced that bypass against the diff-based version).
A dirty flag additionally falls back to asking, so a local edit can still
*disable* the opt-in deliberately. Every unresolved case (not a git repo,
symlinked flag or `.claude`, unreadable git state) reports `enabled=no`, which
merely restores the prompt.
- **What the guard is worth — stated honestly, because the docs are the claim.**
It stops a flag that was merely *written*. It does **not** stop an actor who
can already commit in your repo: they can commit the flag like any other
change, and an agent-authored flag merged through a normal PR is indistinguishable
from a human one. The guard raises the bar from "any file write" to "a commit",
not to "reviewed by a human". What actually bounds the damage is `commit-push`
itself — archive-scoped pathspec, fast-forward only, never a force-push, refusal
on `unpushed-history` — so the worst case is inert archived markdown reaching
`main` unreviewed. **Accepted residual:** requiring the flag's commit to be an
ancestor of `origin/<default>` would make "reviewed" true, but it breaks
origin-less repos, depends on freshly-fetched refs, and is disproportionate to
that blast radius. Revisit only if the flag ever authorizes more than this.
- **Resolution delegates to `main-repo-path.sh`** — the plugin's one resolver, and
the same helper that produces the `<main-repo-path>` `/close` passes in. Sharing
it is the point: an independent second resolver could disagree with `/close`'s,
so `set` would write a flag `get` never reads. An earlier cut open-coded a
`--git-common-dir` + `basename == .git` heuristic (copied from
`agent-registry.sh`) and broke on separate-git-dir/submodule layouts. The
resolver's answer is then **cross-checked** (`--is-inside-work-tree` +
`--show-toplevel`): under `git init --separate-git-dir`, `git worktree list`
reports the *git dir* as the first worktree (verified), and without the check
`set` cheerfully created `.claude/` inside `.git` — a flag that can never be
committed, reported as success. Now such a layout is refused with a diagnostic
naming it. **Caveat to a claim made earlier here:** `agent-registry.sh` still
uses the old heuristic, so the two per-repo files do *not* share one resolver
yet — migrating it is follow-up work, deliberately out of this task's scope
(it sits on the `/kickoff` hot path with its own tests).
- **Sharp edges the script owns** (the accepted-token list, `reason=` codes and
their meanings live in `archive-task.sh`'s header — do not restate them here or
in the README; that vocabulary drifted across five surfaces once already):
- Trim, *not* `tr -d '[:space:]'` — deleting all whitespace collapses `y e s`
into an accepted token, waiving the gate on undocumented content.
- `set`/`unset` refuse a symlinked flag **or `.claude` parent**, and re-validate
after `mkdir -p` — guarding only the leaf let a symlinked `.claude` make
`mkdir` succeed through the link and land the write outside the repo while the
reported path still read in-repo. Residual: a shell can't do this race-free
(no `openat`/`O_NOFOLLOW`); the re-checks narrow the window, they don't close it.
- `set` writes a `mktemp`'d file **in the target directory**, `chmod`s it *before*
the rename, and renames. A fixed `$file.tmp.$$` is PID-predictable (pre-plant
it as a symlink → the redirect clobbers an arbitrary file and `mv` installs the
symlink *as* the flag), and a post-rename `chmod` can follow a swapped-in link.
- `set` warns when `.claude/` is **gitignored** — otherwise `git add` refuses the
path and `get` reports `untracked` forever while `set` claims success.
- `unset` reminds you to commit the *deletion*: removing the file makes `get`
report disabled at once, but HEAD still carries the flag for every other clone.
- The dirty-check compares **blob hashes**, not `git diff --quiet`, so the index
bits (`--assume-unchanged`/`--skip-worktree`) can't hide a deliberate local
disable either.
- The verdict is read from **`refs/heads/<main-branch>`**, not `HEAD` and not the
bare branch name. Reading HEAD made the answer depend on which branch the main
checkout sat on; reading the BARE name was worse — git resolves `refs/tags/`
before `refs/heads/`, so a tag named like the default branch (tags auto-follow
on fetch) supplied an authorization value the branch never carried. Verified.
`get` also declines when the checkout is not on that branch, since
`commit_push` would refuse anyway, and fails **closed** when the branch cannot
be resolved rather than falling back to HEAD.
- `commit_push` passes `:(literal)` pathspecs: a task named `x*` would otherwise
glob `tasks/archive/x*.md` and commit every matching archive. **Not** for
`check-ignore`, which rejects pathspec magic outright — prefixing it there
silently broke the gitignored-archive detection (caught by a smoke test, not
the suite).
- A repo that never opted in must answer a **bare** `enabled=no`: callers relay
any `note=` verbatim, so ordering the symlink check before the existence check
made every close in a symlinked-`.claude` repo warn about an opt-in nobody
configured.
- Sibling helpers are addressed via an absolute `SCRIPT_DIR` captured at load:
a `$(dirname "$0")` expanded inside `( cd "$repo" && … )` resolves against the
*target* repo when `$0` is relative — and runs whatever sits there.
- **`get` ships the human sentence with the machine code.** Alongside `reason=` it
emits a ready-to-print `note=`, and callers relay that instead of re-spelling the
vocabulary — the prose-drift failure this project already recorded.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ entries are grouped per plugin, newest first.

## work-system

### 1.10.0 — 2026-07-24
- `/close` step 10's commit+push prompt is now skippable per repo: a committed `.claude/work-system-close-autocommit` flag (mirrors the `.claude/work-system-agent` default precedent) routes straight to `archive-task.sh commit-push` — no `AskUserQuestion` — and reports the result exactly as the manual path does. Off by default; unset repos keep today's ask-once behavior. Per-repo only, no global default. `archive-task.sh` grew an `autocommit get|set|unset` subcommand as the single source of truth for the flag.
- The flag is honored **only once committed**: `get` reads the value from the committed object on the default branch (`git show refs/heads/<main-branch>:<rel>` — fully qualified, so a same-named tag cannot shadow the branch), never from the working tree, so a file a tool or a worktree agent merely wrote cannot waive the prompt — and neither can a working-tree edit hidden behind `git update-index --assume-unchanged`/`--skip-worktree`, which fools a diff-based guard. A locally edited flag still falls back to asking, so deliberate local disabling works. Scope stated honestly: this raises the bar from "any file write" to "a commit", not to "human-reviewed"; `commit-push`'s own guards (archive-scoped pathspec, ff-only, never force-push, refusal on unpushed history) are what bound the damage.
- The verdict is read from **`<main-branch>`**, not `HEAD`: the flag authorizes a commit onto that branch (`commit-push` refuses any other), so a flag living only on some feature branch no longer announces an auto-commit that then fails. A repo that never opted in answers a bare `enabled=no`; every other non-honored case adds a machine `reason=` and a ready-to-print `note=` that callers relay verbatim instead of duplicating the vocabulary in prose. Accepted content is `yes`/`true`/`1`/`on` (case-insensitive, trimmed, single line); `no`/`false`/`0`/`off` disables, and a deliberate local disable is reported neutrally rather than being told to revert itself. `/close` treats **anything that is not literally `enabled=yes`** — including empty output from an older installed plugin without the subcommand — as "ask", so the check can never fail open, and it prints the same scoped `git status` preview on the auto path: standing authorization is not authorization to act unseen.
- `commit-push` now passes its paths as `:(literal)` pathspecs, so a task name containing glob characters (`x*`) can no longer sweep other archived files into the commit. Both writers in the script use `mktemp` rather than a PID-derived temp name.
- Path handling: resolution delegates to `main-repo-path.sh` (the same resolver `/close` uses, so `set` can't write where `get` won't look); `set`/`unset` refuse a symlinked flag **or `.claude` parent** and verify `.claude` resolves inside the repo; `set` writes via an exclusively-created `mktemp` file in the target directory instead of a PID-predictable `.tmp.$$` path, and echoes the resolved `flag=`.

### 1.9.4 — 2026-07-24
- Add two more herdr tab-glyph refresh triggers: `/kickoff` (after the launch step, so the freshly-created tab is included) and `/define` (after the task file is written). Both call the same best-effort, silent `herdr-tab-glyph.sh refresh --cached <main-repo>` that `/close`/`/list`/`/status` already use — outside herdr, or on failure, it's a silent no-op. Fixes the Manager tab's `◉` hub mark sometimes not appearing until an unrelated `/close`/`/list`/`/status` happened to run in a repo where you mostly `/kickoff`.

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This is a **Claude Code plugin marketplace** (monorepo) containing plugins that
## Current Plugins

- **knowledge-system** (v1.9.x) — Knowledge management with three layers: Rules, Knowledge, Memory. Skills: `/init`, `/query`, `/curate`, `/reindex`, `/backfill-knowledge`, `/migrate`, `/statusline`
- **work-system** (v1.8.x) — Task and worktree workflow. Skills: `/define`, `/kickoff`, `/adopt`, `/continue`, `/status`, `/close`, `/list`, `/statusline`
- **work-system** (v1.10.x) — Task and worktree workflow. Skills: `/define`, `/kickoff`, `/adopt`, `/continue`, `/status`, `/close`, `/list`, `/statusline`
- **pr-flow** (v1.3.x) — PR review feedback loop. Skills: `/open`, `/cycle`, `/check`, `/fix`, `/rebase`, `/merge`
- **swarm** (v0.6.x) — Local mixture-of-agents code review (external `codex`/`grok` CLIs — grok-4.5 — plus Claude lenses: 11 in 4 clusters). Externals get file-read + web research under an OS secret-jail. P2: `/swarm:review` pipeline (scope→fan-out→merge→verify); P5: `--fix`/`--loop` apply the findings you agreed with. Skills: `/swarm:review`, `/swarm:agents`
- **settings** (v0.1.x) — Per-plugin TOML config resolved over schema defaults; each plugin owns its `schema/settings.schema.json`. Skill: `/settings` (list/show/get/set/validate). Phase 1: config surface only.
Expand Down
2 changes: 1 addition & 1 deletion plugins/work-system/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "work-system",
"description": "Generic task and worktree workflow system for Claude Code. Manage tasks as markdown files, run them in isolated git worktrees with a choice of worker agent (Claude, codex, or grok), and track progress across the define/kickoff/continue/status/close lifecycle.",
"version": "1.9.4",
"version": "1.10.0",
"author": {
"name": "gering"
},
Expand Down
24 changes: 24 additions & 0 deletions plugins/work-system/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,30 @@ once to commit **and** fast-forward-push it to `main` (so local `main` never
diverges; a failed push just leaves the commit local). `/list` shows the archived
count in its summary.

**Skip the prompt with a per-repo opt-in.** In a repo where archiving-to-main is
the established norm, the commit+push ask is pure friction. Commit
`.claude/work-system-close-autocommit` (content `yes`) and `/close` skips the
prompt and commits+pushes the archive directly — still reporting the outcome, and
still bounded by the same safety guards (archive-scoped commit, fast-forward
only, never force-pushes, refuses when `main` has other unpushed commits).

Set it with `archive-task.sh autocommit set <repo>` (`unset` to disable, `get` to
check — that subcommand's header documents the exact accepted values and status
codes). Off by default, per-repo only — no global default. The flag is honored
**only once it is committed**: the value is read from the committed version, not
the working tree, so a file that was merely written — by a tool, or by an agent
working in a worktree — never authorizes the skip. Anything unresolved reports
`enabled=no`, you simply get the prompt back, and the tool tells you why. Paths
resolve to the main checkout, so `set` from inside a worktree still writes where
`/close` reads.

Be clear on what that buys you: it raises the bar from *"anything can write a
file"* to *"something had to commit"* — **not** proof a human reviewed it, since
whoever can commit in your repo can commit this too. What bounds the damage is
the commit+push itself: archive-scoped, fast-forward only, never a force-push,
refused when `main` carries other unpushed commits — so the worst case is inert
archived markdown reaching `main` unreviewed.

## Worker agent selection

`/kickoff` doesn't hardcode Claude as the worktree worker. Each agent is a
Expand Down
Loading
Loading