stack-dev/stack-close skill split; publish stack-machinery note; model-selection fix (#47 Block 2)
Files changed: - CHANGES.md - VERSION - instructions/claude-code-model-selection.md - instructions/dev/stack-close/SKILL.md - instructions/dev/stack-dev/SKILL.md - tools/CONTRACT.md - tools/chemenu/commands/git_publish.py - tools/chemenu/tests/test_git_publish.py
This commit is contained in:
+1
-1
@@ -58,7 +58,7 @@ tools/wikitool <command> --help
|
||||
| `sources trace --raw <path>` \| `--page "<Title>"` | Trace provenance in either direction: raw file -> source page(s) -> citing pages, or page -> its sources -> their raw files |
|
||||
| `sources rebuild-index [--dry-run]` | Regenerate the `kb/provenance.md` reverse index (raw file -> source page -> citing pages) |
|
||||
| `sync [--remote origin] [--branch main] [--confirm-rebase TOKEN]` | Fetch `<remote>/<branch>` and bring the local branch up to date with it: fast-forward when the remote is simply ahead, rebase local commit(s) on top when both sides moved but touch disjoint files (a content conflict is then impossible by construction), and exit **42** for review when they touch the same file (the **rebase-review gate** - see `publish` below). Never commits, never pushes, never force-anything - no remote configured, or one that cannot be reached, is reported and skipped, not a failure. Meant to run once at the start of a writing session (`instructions/session-setup.md`) so the rest of it works against a current tree instead of discovering the drift at the final `publish` |
|
||||
| `publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] [--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] [--path P ...]` | Reconcile with `<remote>/<branch>` exactly like `sync` (skipped for `--no-push`), then stage all changes, commit, and push. If the reconcile step found a still-unpushed local commit and there is nothing new to stage, that commit is pushed anyway - a previous `publish` whose push failed no longer strands it. If the push is rejected despite the pre-check (a genuine race - something landed on the remote in between), one more reconcile-and-retry is attempted before giving up; never more than one. **Mass-Update Gate:** when >= `--threshold` (default 10) *counted* files would be committed, exits **42 (`EXIT_NEEDS_CLEARANCE`)** instead of publishing - a third outcome distinct from success (0) and a validation error (1) - and prints a review report: a scale line (file count, total lines added/removed, status breakdown), only-what-applies attention notes (deletions by name, control-plane and harness-config touches, published pages, the largest single change, binaries), and every counted path grouped by area with its status and churn, generated files split out as needing no review. The token digests each counted path **and its contents** plus the publish target, so a clearance carries neither to a different file list nor to edited contents; a wrong, invented or superseded token exits 42 again with the current state. Two kinds of path are committed but never counted and never shown for approval: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`) - each is recomputable from the tree, so approving it decides nothing, and a routine ingest rebuilds five or six of them. The refusal line accounts for both, by reason. The gate is evaluated *before* anything is staged, so a refused publish leaves the working tree untouched. **Publish-Remote Gate:** when this checkout carries a `.wikitool-remotes.json` and the resolved push URL of `--remote` is not listed in it, exits **42** before the reconcile step even fetches - the URL is read from `git remote get-url --push`, so a repointed remote does not pass on its name. Unlike the other two gates it has **no token and no flag**: the way past it is the user adding the URL to that file, and an agent editing it to get past a refusal is opening a gate on its own initiative. Absent file means unrestricted; a malformed one is an error, not permission. See [instructions/gates.md](../instructions/gates.md) `--yes`/`-y` are gone and now fail with an explicit error. `--path` (repeatable) scopes the whole operation - gate count, staging, and commit - to a subtree |
|
||||
| `publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] [--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] [--path P ...]` | Reconcile with `<remote>/<branch>` exactly like `sync` (skipped for `--no-push`), then stage all changes, commit, and push. If the reconcile step found a still-unpushed local commit and there is nothing new to stage, that commit is pushed anyway - a previous `publish` whose push failed no longer strands it. If the push is rejected despite the pre-check (a genuine race - something landed on the remote in between), one more reconcile-and-retry is attempted before giving up; never more than one. **Mass-Update Gate:** when >= `--threshold` (default 10) *counted* files would be committed, exits **42 (`EXIT_NEEDS_CLEARANCE`)** instead of publishing - a third outcome distinct from success (0) and a validation error (1) - and prints a review report: a scale line (file count, total lines added/removed, status breakdown), only-what-applies attention notes (deletions by name, control-plane and harness-config touches, published pages, the largest single change, binaries), and every counted path grouped by area with its status and churn, generated files split out as needing no review. The token digests each counted path **and its contents** plus the publish target, so a clearance carries neither to a different file list nor to edited contents; a wrong, invented or superseded token exits 42 again with the current state. Two kinds of path are committed but never counted and never shown for approval: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`) - each is recomputable from the tree, so approving it decides nothing, and a routine ingest rebuilds five or six of them. The refusal line accounts for both, by reason. The gate is evaluated *before* anything is staged, so a refused publish leaves the working tree untouched. **Publish-Remote Gate:** when this checkout carries a `.wikitool-remotes.json` and the resolved push URL of `--remote` is not listed in it, exits **42** before the reconcile step even fetches - the URL is read from `git remote get-url --push`, so a repointed remote does not pass on its name. Unlike the other two gates it has **no token and no flag**: the way past it is the user adding the URL to that file, and an agent editing it to get past a refusal is opening a gate on its own initiative. Absent file means unrestricted; a malformed one is an error, not permission. See [instructions/gates.md](../instructions/gates.md) `--yes`/`-y` are gone and now fail with an explicit error. `--path` (repeatable) scopes the whole operation - gate count, staging, and commit - to a subtree. **Stack-machinery note:** after a successful commit/push whose changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md`, or any `<stage>/CONTRACT.md` - the same scope a stack version bump itself covers - prints one reminder line that the phase past this point (an issue-body rewrite, `docs/` staleness, a changelog entry's accuracy) is not covered by `docs verify`, `instructions verify` or `pytest`. Not a gate: no exit code change, nothing to clear, silent for an ordinary content publish |
|
||||
| `work new (--input <raw path> \| --key <run key>) [--again] [--dry-run]` | Scaffold `work/<runkey>/` for one workshop run: refuses a collision instead of suffixing it, and writes the required `README.md` + `plan.md`. `--input` derives the run key from the path below `raw/` (an ingest); `--key` names it outright for a run with no raw input - a migration or a sweep across `kb/` - and may not start with `ingest-`, which stays reserved for derived keys. Exactly one of the two. `--again` opens a dated second pass over a tree that has itself changed. See [work/CONTRACT.md](../work/CONTRACT.md) |
|
||||
| `work close --run-key <name> [--yes] [--dry-run]` | Delete a finished workshop. Lists what would be lost and requires `--yes`, because nothing in it is recoverable from the rest of the repo - the durable conclusions must already be in `kb/` |
|
||||
| `budget status` | Show the current session's `wikitool` call count and recent command history (never counted against the budget) |
|
||||
|
||||
@@ -423,6 +423,41 @@ def counted_files_of(
|
||||
return [change for change in changes if not is_exempt(change.path, prefixes)]
|
||||
|
||||
|
||||
# The same scope `version-parts.md` names for the stack version itself -
|
||||
# "tools/, types/, instructions/, AGENTS.md and the contracts" - reused here
|
||||
# to decide whether a publish's changeset falls under it. Not a copy of that
|
||||
# rule: version-parts.md states the scope in prose for a human choosing a
|
||||
# version part, this instantiates the same boundary in code for a different
|
||||
# question (does this publish deserve the closing-phase reminder below).
|
||||
STACK_MACHINERY_PREFIXES = ("tools/", "types/", "instructions/")
|
||||
STACK_MACHINERY_NAMES = ("AGENTS.md",)
|
||||
|
||||
STACK_MACHINERY_NOTE = (
|
||||
"Note: this publish touched stack machinery. What a stack-dev session "
|
||||
"does next - closing prose, a changelog entry's accuracy, whether a "
|
||||
"docs/ page went stale - is not covered by docs verify, instructions "
|
||||
"verify, or pytest. No tool checks it; a session has to."
|
||||
)
|
||||
|
||||
|
||||
def touches_stack_machinery(changed_files: list[str]) -> bool:
|
||||
"""Whether `changed_files` includes a path under `version-parts.md`'s
|
||||
scope for the stack version - `tools/`, `types/`, `instructions/`,
|
||||
`AGENTS.md`, or any `<stage>/CONTRACT.md`. A publish in this class is,
|
||||
by construction of the `stack-dev`/`stack-close` split, always followed
|
||||
by the unchecked closing phase - `STACK_MACHINERY_NOTE` times a reminder
|
||||
to land exactly there, for any session, not only one that read the
|
||||
skill that names it."""
|
||||
for path in changed_files:
|
||||
if path in STACK_MACHINERY_NAMES:
|
||||
return True
|
||||
if path.endswith("CONTRACT.md"):
|
||||
return True
|
||||
if path.startswith(STACK_MACHINERY_PREFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
YES_REMOVED_MESSAGE = (
|
||||
"--yes no longer exists. The Mass-Update Gate is cleared with `--confirm <token>`, and the "
|
||||
"token comes from the gate's own refusal output - run this command without it first to see "
|
||||
@@ -1138,3 +1173,5 @@ def publish_command(
|
||||
},
|
||||
)
|
||||
success(f"Published changes to {remote}/{branch}." if push else "Committed changes (not pushed).")
|
||||
if touches_stack_machinery(changed_files):
|
||||
typer.echo(STACK_MACHINERY_NOTE)
|
||||
|
||||
@@ -10,6 +10,7 @@ from chemenu.commands._util import EXIT_NEEDS_CLEARANCE
|
||||
from chemenu.commands.git_publish import (
|
||||
DEFAULT_MASS_UPDATE_THRESHOLD,
|
||||
GATE_EXEMPT_PREFIXES,
|
||||
STACK_MACHINERY_NOTE,
|
||||
YES_REMOVED_MESSAGE,
|
||||
FileChange,
|
||||
attention_notes,
|
||||
@@ -29,6 +30,7 @@ from chemenu.commands.git_publish import (
|
||||
rerun_command,
|
||||
scale_line,
|
||||
sync_command,
|
||||
touches_stack_machinery,
|
||||
)
|
||||
|
||||
|
||||
@@ -290,6 +292,26 @@ def test_gate_message_names_generated_files_as_their_own_reason():
|
||||
assert "kb/provenance.md" not in message
|
||||
|
||||
|
||||
def test_stack_machinery_detects_the_version_parts_scope():
|
||||
"""The same boundary version-parts.md names for the stack version: tools/,
|
||||
types/, instructions/, AGENTS.md, and any <stage>/CONTRACT.md."""
|
||||
assert touches_stack_machinery(["instructions/dev/issue-tracking.md"])
|
||||
assert touches_stack_machinery(["tools/chemenu/commands/git_publish.py"])
|
||||
assert touches_stack_machinery(["types/instruction.md"])
|
||||
assert touches_stack_machinery(["AGENTS.md"])
|
||||
assert touches_stack_machinery(["kb/CONTRACT.md"])
|
||||
assert touches_stack_machinery(["raw/CONTRACT.md"])
|
||||
|
||||
|
||||
def test_stack_machinery_excludes_ordinary_content():
|
||||
"""kb/ content, docs/ prose and work/ scratch carry no normative sentence
|
||||
and are not what the closing-phase reminder is about."""
|
||||
assert not touches_stack_machinery(["kb/entities/systems/Foo.md"])
|
||||
assert not touches_stack_machinery(["docs/why-gates-are-code.md"])
|
||||
assert not touches_stack_machinery(["work/ingest-x/extract-0.md"])
|
||||
assert not touches_stack_machinery([])
|
||||
|
||||
|
||||
# --- publish_command integration: a real git repo + a local bare remote ---
|
||||
|
||||
|
||||
@@ -356,6 +378,24 @@ def test_below_threshold_publish_goes_straight_through(repo):
|
||||
assert _git(repo, "status", "--porcelain", "-uall").stdout == ""
|
||||
|
||||
|
||||
def test_publish_notes_stack_machinery_after_success(repo, capsys):
|
||||
"""The closing-phase reminder lands exactly once, after the OK line, and
|
||||
only when the changeset actually falls under version-parts.md's scope."""
|
||||
(repo / "instructions").mkdir()
|
||||
(repo / "instructions/example.md").write_text("x\n", encoding="utf-8")
|
||||
_publish(message="touch instructions")
|
||||
out = capsys.readouterr().out
|
||||
assert STACK_MACHINERY_NOTE in out
|
||||
assert out.count(STACK_MACHINERY_NOTE) == 1
|
||||
|
||||
|
||||
def test_publish_stays_quiet_for_ordinary_content(repo, capsys):
|
||||
_write_files(repo, 1)
|
||||
_publish(message="ordinary content")
|
||||
out = capsys.readouterr().out
|
||||
assert STACK_MACHINERY_NOTE not in out
|
||||
|
||||
|
||||
def test_at_threshold_publish_asks_for_clearance_and_stages_nothing(repo):
|
||||
_write_files(repo, 10)
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
|
||||
Reference in New Issue
Block a user