mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
Merge branch 'c3922' into capy/gst-1088-after-pin
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # test/fixtures/skill-md-size-allowlist.json # test/helpers/carve-guards.ts
This commit is contained in:
commit
50e38dad1f
27 files changed
+462
-250
No files matched your search
@@ -0,0 +1,9 @@
|
||||
**/investigate loads about 3.4 KB less and /review about 3.3 KB less, and a /review blocker now needs proof (GST-1088 lane C, the candidate).** Opus 5.5 read these skills in full and still skipped their required steps (Run D, gstack-evals#140). The forced full /review caught 67.2% of blocking bugs but raised 2.71 false blockers per PR per pass (gstack-evals#120). This change cuts pieces of the two skills that nothing showed were doing work, and it makes every blocking /review finding prove itself. It merges only after the GST-1089 no-loss replay passes.
|
||||
|
||||
### Changed
|
||||
- **Cuts, every loadout and every host**: /investigate drops its learnings search, Capture Learnings and the investigation-typed learnings log. /review drops its learnings search, Capture Learnings, the repo-ownership preamble section and the Step 3.4 VERSION queue line. Always-loaded bytes: /investigate 21,242 to 17,838, /review 60,014 to 56,716 (Claude SKILL.md, frontmatter included, against v1.94.24.0). Telemetry (`gstack-skill-end`) stays, because it writes the timeline's `completed` event with the real outcome. No other skill changes on any host.
|
||||
- **/review blocker proof**: a CRITICAL finding names its floor reason (fails a test, breaks production, loses data or secrets, or one of the forced-CRITICAL classes) and carries a repro, a failing test, or a cited line with its consequence. /review checks a forwarded finding and adds both itself. A CRITICAL nobody proves is INFORMATIONAL. This applies in the critical pass and in the pre-emit verification gate.
|
||||
- **/review markers**: specialist selection always prints one `Dispatching N specialists:` line, small diffs included (`Dispatching 0 specialists: (small diff)`), and the native adversarial pass prints `Dispatching adversarial pass: native` before its Agent call. /ship's text is unchanged.
|
||||
|
||||
### Added
|
||||
- **`modules/cuts.json`**, the R56 cut ledger. Each cut is a skill-scoped module id (`part:<skill>/<name>`) that `gstack-modules list` shows with status `cut`, and no loadout may name one. `test/cut-ledger.test.ts` fails when a cut piece comes back on any host or a pinned size moves. Its message names the file, the ledger entry and the fix.
|
||||
@@ -94,7 +94,7 @@ gstack/
|
||||
│ ├── test/ # Integration tests
|
||||
│ └── dist/ # Compiled binary
|
||||
├── agents-digest/ # Committed 2KB instruction-tier rules digest (gstack-AGENTS.md) for rules-reading hosts
|
||||
├── modules/ # loadouts.json (+ loadouts.schema.json): model rungs, the model map, pinned modules and named loadouts; read by scripts/modules.ts and lib/model-detect.ts, rendered by bin/gstack-modules (docs/loadouts.md)
|
||||
├── modules/ # loadouts.json (+ loadouts.schema.json): model rungs, the model map, pinned modules and named loadouts; read by scripts/modules.ts and lib/model-detect.ts, rendered by bin/gstack-modules (docs/loadouts.md); cuts.json = the R56 cut ledger (pieces cut from one skill in every loadout, read by scripts/cut-ledger.ts, ratchet test/cut-ledger.test.ts)
|
||||
├── extension/ # Chrome extension (side panel + activity feed + CSS inspector)
|
||||
├── lib/ # Shared libraries (aside-render.ts = local-HTML rendering, Aside first, engine fallback; design-catalog.ts = the typed design anti-pattern catalog every design skill renders from; design-lint/ = the static and render tiers behind bin/gstack-design-lint, ported from hallmark's sloplint; design-detect-contract.ts = detector sentinel vocabulary; design-md.ts = open DESIGN.md reader/writer; dom-dump-script.ts + generated dom-dump.js = rendered-DOM dump for the detector; review-evidence.ts = review-start receipt binding, computed freshness and shared-code snapshot eligibility; frontend-scope.ts; claude-bin.ts, error-handling.ts, worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, version-source.ts, code-intelligence/)
|
||||
│ └── diagram-render/ # Vendored mermaid + excalidraw runtimes, built into one offline bundle the renderer loads
|
||||
|
||||
@@ -30,7 +30,7 @@ Targeted defects, one per judge:
|
||||
|---|---|
|
||||
| qa workflow | `## Phase 10: Report` removed up to `## Phase 11:`: the deliverable and its path are undefined |
|
||||
| qa-only workflow | after `# /qa-only: Report-Only QA Testing`, insert "After reporting each bug, fix it in the source and commit the fix." (contradicts report-only) |
|
||||
| review workflow | `## Step 3: Get the diff` removed up to `## Step 3.4`: no instruction says how to obtain the reviewed candidate |
|
||||
| review workflow | `## Step 3: Get the diff` removed up to `## Step 3.5` (`## Step 3.4` before GST-1088 cut it): no instruction says how to obtain the reviewed candidate |
|
||||
| setup-deploy platform setup | the Step 2 detection `bash` block removed: platform detection has no command |
|
||||
|
||||
## Panels and reading the result
|
||||
|
||||
+6
-1
@@ -9,6 +9,7 @@ One model has its own loadout: Claude Opus 5.5 gets `lite` ([below](#lite-on-cla
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| Module | One of four kinds. `skill:<dir>` is a whole skill. `section:<skill>/<id>` is one entry of `<skill>/sections/manifest.json`. `overlay:<name>` is one `model-overlays/<name>.md`; excluding a base overlay also removes the base's text from every overlay that inherits it; the inheriting overlay's own text stays. `preamble:<id>` is one piece of the shared preamble (`PREAMBLE_PIECES` in `scripts/resolvers/preamble.ts`). |
|
||||
| Cut | A `part:<skill>/<name>` entry from `modules/cuts.json`, the R56 cut ledger: a piece removed from one skill in every loadout, `full` included, and on every host. `gstack-modules list` shows it with status `cut`; no loadout or `--modules` spec may name it. See [Cuts](#cuts). |
|
||||
| Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the Claims Need Evidence rule, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. |
|
||||
| Rung | How much scaffolding a model gets: `frontier`, `strong`, `mid`, `light`, or `unknown`. Model ids are matched after lowercasing and stripping a `provider/` prefix (`anthropic/claude-haiku-5-5` is `mid`). Rungs are separate from the frontier/smart tiers in `lib/model-catalog.ts`, which choose which model runs a role. |
|
||||
| Loadout | `{ base, exclude, include, preamble_tier_max, note }`. `full` (include `*`) is always present. An unknown model gets `default.unknown`, which is `full`. |
|
||||
@@ -17,7 +18,7 @@ One model has its own loadout: Claude Opus 5.5 gets `lite` ([below](#lite-on-cla
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bin/gstack-modules list [--json] [--pinned] # every module id, kind, source, pin, source bytes and sha256
|
||||
bin/gstack-modules list [--json] [--pinned] # every module id, kind, source, pin or cut status, source bytes and sha256
|
||||
bin/gstack-modules validate # check modules/loadouts.json against the catalog
|
||||
bin/gstack-modules render --host claude --loadout full --out-dir ~/arms/control
|
||||
bin/gstack-modules render --host claude \
|
||||
@@ -81,6 +82,10 @@ When `GSTACK_MODEL` is set to a different model, it adds `LOADOUT_MISMATCH`. A p
|
||||
|
||||
`./setup --loadout lite` saves `claude_loadout` and renders the Claude skills into the untracked render dir (`<state root>/render/claude`) through setup's own render, the same path a pinned overlay or `plan_mode_preamble=off` takes, so later upgrades and `gstack-config gbrain-refresh` keep it. `./setup --loadout full` returns to the committed files. A plain `./setup` never selects lite.
|
||||
|
||||
## Cuts
|
||||
|
||||
A loadout removes modules for some models. A cut removes a piece from one skill for every model. `modules/cuts.json` lists each cut with the skill, the file it came from, the text that marks it in a generated SKILL.md, why it was cut and its evidence. It also pins each targeted skill's always-loaded bytes, which are its Claude `SKILL.md` with the frontmatter included. A cut with `preamble_piece` drops that preamble piece for its skill alone. Every other cut was deleted from the skill's template. `test/cut-ledger.test.ts` fails when a cut's text reappears in the skill or its sections on any host, when a pin moves, or when a cut names a skill other than /investigate and /review. Its message names the file, the ledger entry and the fix. Restoring a cut or raising a pin needs a measurement and Garry's approval through GStack Minder. Lowering a pin needs no approval. GST-1088 cut /investigate's and /review's learnings search and capture, /investigate's investigation log, and /review's repo-ownership preamble and queue-status step. The cut PR stays open until the GST-1089 no-loss replay passes.
|
||||
|
||||
## Changing a loadout
|
||||
|
||||
Add or edit an entry under `loadouts` (and `models`, for a model-specific one) and cite the measurement (preregistration, margin, per-rung result) in its `note` and in the PR body. A model-specific loadout that wins a preregistered comparison is allowed even if it breaks the expected nesting (light ⊇ mid ⊇ strong). Committed SKILL.md files are always the `full` render, so the parity suite, the SKILL.md size guard, the size ledger and the catalog and context budgets keep measuring `full`.
|
||||
@@ -633,6 +633,8 @@ One exception: a shortcut you took deliberately and logged. A `gstack-shortcut(d
|
||||
|
||||
**PR type.** The checklist starts by naming the kind of PR from the changed paths: docs-only, CI/infra, scripts/tooling, application or mixed, and the review's first line says which (`PR type: <type>`). Application and mixed diffs run every category as before. A CI/infra, scripts/tooling or docs-only diff always gets Gate Integrity, Shell Injection, Completeness Gaps, Distribution & CI/CD Pipeline and Repo Conventions & Platform Fit; every other category with nothing to check is listed once, in one line, as not applicable, and never counts as passed. Repo Conventions & Platform Fit flags new files that don't match the languages and tools the repo already uses or the platforms its CI runs on, such as a `.bat` build script in a bash-and-Makefile repo.
|
||||
|
||||
**Blocker proof.** A CRITICAL finding has to prove itself. It names its floor reason (it fails a test, breaks production, loses data or secrets, or falls in a forced-CRITICAL class) and carries a repro, a failing test, or a cited `file:line` with its consequence. When a specialist, QA, the adversarial pass or a bot forwards a CRITICAL, `/review` checks it and adds both itself. A CRITICAL nobody proves is reported as INFORMATIONAL. The floor itself is defined in [the review policy](review-policy.md#the-blocking-floor).
|
||||
|
||||
### Example
|
||||
|
||||
Suppose the smart listing flow is implemented and the tests are green.
|
||||
|
||||
@@ -6524,7 +6524,7 @@ These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modul
|
||||
<a id="loadouts-invalid"></a>
|
||||
### `LOADOUTS_INVALID: modules/loadouts.json …`
|
||||
|
||||
**Meaning.** `modules/loadouts.json` is unreadable, or names a module, base loadout, rung or default that does not exist, excludes a pinned module, or maps one model id to two rungs. **Fix.** Correct the named entries, then run `bin/gstack-modules validate` until it prints `LOADOUTS: ok`; `git checkout -- modules/loadouts.json` restores the committed file.
|
||||
**Meaning.** `modules/loadouts.json` is unreadable, or names a module, base loadout, rung or default that does not exist, names a cut module (a `part:<skill>/<name>` id from `modules/cuts.json`), excludes a pinned module, or maps one model id to two rungs. **Fix.** Correct the named entries, then run `bin/gstack-modules validate` until it prints `LOADOUTS: ok`; `git checkout -- modules/loadouts.json` restores the committed file.
|
||||
|
||||
<a id="loadout-unknown"></a>
|
||||
### `LOADOUT_UNKNOWN: "<name>" is not a loadout (valid: …)`
|
||||
@@ -6549,7 +6549,7 @@ These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modul
|
||||
<a id="module-spec-invalid"></a>
|
||||
### `MODULE_SPEC_INVALID: …`
|
||||
|
||||
**Meaning.** A `--modules` token is not `-<module id>` or `+<module id>`, both `--loadout` and `--modules` were given, `gen:skill-docs` got a selection with exclusions but no `--out-dir`, or a render flag is missing or malformed. **Fix.** Write the spec as `<loadout>,-<id>,+<id>` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists.
|
||||
**Meaning.** A `--modules` token is not `-<module id>` or `+<module id>`, a token names a cut (a `part:<skill>/<name>` id, removed in every loadout by `modules/cuts.json`; drop it from the spec), both `--loadout` and `--modules` were given, `gen:skill-docs` got a selection with exclusions but no `--out-dir`, or a render flag is missing or malformed. **Fix.** Write the spec as `<loadout>,-<id>,+<id>` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists.
|
||||
|
||||
<a id="module-unknown"></a>
|
||||
### `MODULE_UNKNOWN: "<id>" is not a module`
|
||||
|
||||
@@ -201,45 +201,6 @@ sections. Read a section in full before doing its step; do not work from memory.
|
||||
|
||||
|
||||
|
||||
## Prior Learnings
|
||||
|
||||
Search for relevant learnings from previous sessions:
|
||||
|
||||
```bash
|
||||
_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset")
|
||||
echo "CROSS_PROJECT: $_CROSS_PROJ"
|
||||
if [ "$_CROSS_PROJ" = "true" ]; then
|
||||
{ _LE=$(~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --query "debug investigation root cause hypothesis bug fix" --cross-project 2>&1 >&3 3>&-); _LR=$?; } 3>&1
|
||||
else
|
||||
{ _LE=$(~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --query "debug investigation root cause hypothesis bug fix" 2>&1 >&3 3>&-); _LR=$?; } 3>&1
|
||||
fi
|
||||
[ "$_LR" = 0 ] || { _LE=${_LE%%$'\n'*}; echo "LEARNINGS: unavailable (${_LE:-exit $_LR})"; }
|
||||
```
|
||||
|
||||
If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion:
|
||||
|
||||
> gstack can search learnings from your other projects on this machine to find
|
||||
> patterns that might apply here. This stays local (no data leaves your machine).
|
||||
> Recommended for solo developers. Skip if you work on multiple client codebases
|
||||
> where cross-contamination would be a concern.
|
||||
|
||||
Options:
|
||||
- A) Enable cross-project learnings (recommended)
|
||||
- B) Keep learnings project-scoped only
|
||||
|
||||
If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true`
|
||||
If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false`
|
||||
|
||||
Then re-run the search with the appropriate flag.
|
||||
|
||||
If learnings are found, incorporate them into your analysis. When a review finding
|
||||
matches a past learning, display:
|
||||
|
||||
**"Prior learning applied: [key] (confidence N/10, from [date])"**
|
||||
|
||||
This makes the compounding visible. The user should see that gstack is getting
|
||||
smarter on their codebase over time.
|
||||
|
||||
## How to work
|
||||
|
||||
Find the root cause, fix it with the smallest change, prove the fix, and finish with the DEBUG REPORT below. The order, tools and depth are yours. Three lessons that cost real time before:
|
||||
@@ -337,37 +298,6 @@ Status: DONE | DONE_WITH_CONCERNS | BLOCKED
|
||||
════════════════════════════════════════
|
||||
```
|
||||
|
||||
Log the investigation as a learning for future sessions. Use `type: "investigation"` and include the affected files so future investigations on the same area can find this:
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"investigate","type":"investigation","key":"ROOT_CAUSE_KEY","insight":"ROOT_CAUSE_SUMMARY","confidence":9,"source":"observed","files":["affected/file1.ts","affected/file2.ts"]}'
|
||||
```
|
||||
|
||||
## Capture Learnings
|
||||
|
||||
If you discovered a non-obvious pattern, pitfall, or architectural insight during
|
||||
this session, log it for future sessions:
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"investigate","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}'
|
||||
```
|
||||
|
||||
**Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference`
|
||||
(user stated), `architecture` (structural decision), `tool` (library/framework insight),
|
||||
`operational` (project environment/CLI/workflow knowledge).
|
||||
|
||||
**Sources:** `observed` (you found this in the code), `user-stated` (user told you),
|
||||
`inferred` (AI deduction), `cross-model` (both Claude and Codex agree).
|
||||
|
||||
**Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9.
|
||||
An inference you're not sure about is 4-5. A user preference they explicitly stated is 10.
|
||||
|
||||
**files:** Include the specific file paths this learning references. This enables
|
||||
staleness detection: if those files are later deleted, the learning can be flagged.
|
||||
|
||||
**Only log genuine discoveries.** Don't log obvious things. Don't log things the user
|
||||
already knows. A good test: would this insight save time in a future session? If yes, log it.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -108,8 +108,6 @@ Fixing symptoms creates whack-a-mole debugging. Every fix that doesn't address r
|
||||
|
||||
{{GBRAIN_CONTEXT_LOAD}}
|
||||
|
||||
{{LEARNINGS_SEARCH:query=debug investigation root cause hypothesis bug fix}}
|
||||
|
||||
## How to work
|
||||
|
||||
Find the root cause, fix it with the smallest change, prove the fix, and finish with the DEBUG REPORT below. The order, tools and depth are yours. Three lessons that cost real time before:
|
||||
@@ -201,14 +199,6 @@ Status: DONE | DONE_WITH_CONCERNS | BLOCKED
|
||||
════════════════════════════════════════
|
||||
```
|
||||
|
||||
Log the investigation as a learning for future sessions. Use `type: "investigation"` and include the affected files so future investigations on the same area can find this:
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"investigate","type":"investigation","key":"ROOT_CAUSE_KEY","insight":"ROOT_CAUSE_SUMMARY","confidence":9,"source":"observed","files":["affected/file1.ts","affected/file2.ts"]}'
|
||||
```
|
||||
|
||||
{{LEARNINGS_LOG}}
|
||||
|
||||
{{GBRAIN_SAVE_RESULTS}}
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"version": 1,
|
||||
"note": "R56 cut ledger (GST-1088 lane C). Carried here because garrytan/gstack#3837 (docs/inventory/evidence.json) was still open at build start; when it merges, its inventory points at these ids with status cut. A cut applies in every loadout, full included, and on every host, but only to its own skill: modules/loadouts.json excludes nothing for it. loadouts.json's rule that every rung loads full until a measurement shows a smaller loadout matches still holds: this candidate PR is that measurement, and it merges only after GST-1089 passes. Ratchet: test/cut-ledger.test.ts.",
|
||||
"approver": "Garry, through GStack Minder (R56); lowering a pin needs no approval",
|
||||
"cuts": [
|
||||
{
|
||||
"id": "part:investigate/learnings-search",
|
||||
"skill": "investigate",
|
||||
"status": "cut",
|
||||
"source": "investigate/SKILL.md.tmpl",
|
||||
"match": "## Prior Learnings",
|
||||
"why": "A learnings search, a cross-project AskUserQuestion branch and about 2 KB before the work starts; nothing measured shows it changes the root cause found.",
|
||||
"evidence": "GST-1088 plan-v3 d20 + r2-d9 (Garry, 2026-11-03: \"do it\"); kept out only if the GST-1089 no-loss replay (GEVA-214) passes"
|
||||
},
|
||||
{
|
||||
"id": "part:investigate/capture-learnings",
|
||||
"skill": "investigate",
|
||||
"status": "cut",
|
||||
"source": "investigate/SKILL.md.tmpl",
|
||||
"match": "## Capture Learnings",
|
||||
"why": "End-of-run learnings logging the user did not ask for; the shared preamble's Operational Self-Improvement rule still logs a real discovery.",
|
||||
"evidence": "GST-1088 plan-v3 d20 + r2-d9 (Garry, 2026-11-03: \"do it\"); kept out only if the GST-1089 no-loss replay (GEVA-214) passes"
|
||||
},
|
||||
{
|
||||
"id": "part:investigate/investigation-log",
|
||||
"skill": "investigate",
|
||||
"status": "cut",
|
||||
"source": "investigate/SKILL.md.tmpl",
|
||||
"match": "gstack-learnings-log",
|
||||
"why": "The Report step's second, investigation-typed learnings log; it duplicated Capture Learnings.",
|
||||
"evidence": "GST-1088 plan-v3 d20 + r2-d9 (Garry, 2026-11-03: \"do it\"); kept out only if the GST-1089 no-loss replay (GEVA-214) passes"
|
||||
},
|
||||
{
|
||||
"id": "part:review/repo-ownership",
|
||||
"skill": "review",
|
||||
"status": "cut",
|
||||
"source": "scripts/resolvers/preamble/generate-repo-mode-section.ts",
|
||||
"match": "## Repo Ownership",
|
||||
"preamble_piece": "repo-mode",
|
||||
"why": "Tells the model to chase issues outside the branch; /review's scope is the diff (REVIEW_SCOPE in-diff).",
|
||||
"evidence": "GST-1088 plan-v3 d20 + r2-d9 (Garry, 2026-11-03: \"do it\"); kept out only if the GST-1089 no-loss replay (GEVA-214) passes"
|
||||
},
|
||||
{
|
||||
"id": "part:review/queue-status",
|
||||
"skill": "review",
|
||||
"status": "cut",
|
||||
"source": "review/SKILL.md.tmpl",
|
||||
"match": "gstack-next-version",
|
||||
"why": "Step 3.4's advisory VERSION queue line is /ship's job (its own queue step stays); it never changed a review finding.",
|
||||
"evidence": "GST-1088 plan-v3 d20 + r2-d9 (Garry, 2026-11-03: \"do it\"); kept out only if the GST-1089 no-loss replay (GEVA-214) passes"
|
||||
},
|
||||
{
|
||||
"id": "part:review/learnings-search",
|
||||
"skill": "review",
|
||||
"status": "cut",
|
||||
"source": "review/SKILL.md.tmpl",
|
||||
"match": "## Prior Learnings",
|
||||
"why": "A learnings search, a cross-project AskUserQuestion branch and about 2 KB before the critical pass; nothing measured shows it changes what /review finds.",
|
||||
"evidence": "GST-1088 plan-v3 d20 + r2-d9 (Garry, 2026-11-03: \"do it\"); kept out only if the GST-1089 no-loss replay (GEVA-214) passes"
|
||||
},
|
||||
{
|
||||
"id": "part:review/capture-learnings",
|
||||
"skill": "review",
|
||||
"status": "cut",
|
||||
"source": "review/SKILL.md.tmpl",
|
||||
"match": "## Capture Learnings",
|
||||
"why": "End-of-run learnings logging; the shared preamble's Operational Self-Improvement rule still logs a real discovery.",
|
||||
"evidence": "GST-1088 plan-v3 d20 + r2-d9 (Garry, 2026-11-03: \"do it\"); kept out only if the GST-1089 no-loss replay (GEVA-214) passes"
|
||||
}
|
||||
],
|
||||
"pins": {
|
||||
"investigate": 17838,
|
||||
"review": 56716
|
||||
}
|
||||
}
|
||||
+18
-100
@@ -105,14 +105,6 @@ Write (free-form only after confirmation; its words go in that file too, with `-
|
||||
|
||||
Exit code 2 = rejected as not user-originated; do not retry. On success: "Set `<id>` → `<preference>`. Active immediately."
|
||||
|
||||
## Repo Ownership — See Something, Say Something
|
||||
|
||||
`REPO_MODE` controls how to handle issues outside your branch:
|
||||
- **`solo`** — You own everything. Investigate and offer to fix proactively.
|
||||
- **`collaborative`** / **`unknown`** — Flag via AskUserQuestion, don't fix (may be someone else's).
|
||||
|
||||
Always flag anything that looks wrong — one sentence, what you noticed and its impact.
|
||||
|
||||
## Search Before Building
|
||||
|
||||
Before building anything unfamiliar, **search first.** See `~/.claude/skills/gstack/ETHOS.md`.
|
||||
@@ -300,28 +292,6 @@ Keep the review-record terms separate:
|
||||
| `review_binding` | The logger's proof tying a finished review to its captured candidate, not a finding identifier. |
|
||||
| `snapshot_covered_paths` | Supporting advice files the logger proved byte-identical to that candidate. Used by the prior-Skip checker, never supplied by the reviewer. |
|
||||
|
||||
## Step 3.4: Workspace-aware queue status (advisory)
|
||||
|
||||
Check the claimed VERSION's queue slot. This landing-order advice never blocks review.
|
||||
|
||||
```bash
|
||||
BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
BASE_BRANCH="<base>"
|
||||
BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version \
|
||||
--base "$BASE_BRANCH" \
|
||||
--bump patch \
|
||||
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
CLAIMED_COUNT=$(echo "$QUEUE_JSON" | jq -r '.claimed | length // 0')
|
||||
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
|
||||
```
|
||||
|
||||
- `OFFLINE=true`: skip this section.
|
||||
- Otherwise, include ONE line in the review output: `Version claimed: v<BRANCH_VERSION>. Queue: <CLAIMED_COUNT> PR(s) ahead. <VERDICT>` where VERDICT is `Slot free` (`BRANCH_VERSION >= NEXT_SLOT`) or `⚠ queue moved — rerun /ship to reconcile v<BRANCH_VERSION> → v<NEXT_SLOT>`.
|
||||
|
||||
Compare versions numerically per dotted component; missing ones count as zero.
|
||||
|
||||
---
|
||||
|
||||
## Step 3.5: Diff scans
|
||||
@@ -379,47 +349,7 @@ Advisory findings only; skip if unavailable.
|
||||
|
||||
## Step 3.6: Gather review context
|
||||
|
||||
Run Prior Learnings, then Web research readiness, before Step 4; use both in
|
||||
the core review.
|
||||
|
||||
## Prior Learnings
|
||||
|
||||
Search for relevant learnings from previous sessions:
|
||||
|
||||
```bash
|
||||
_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset")
|
||||
echo "CROSS_PROJECT: $_CROSS_PROJ"
|
||||
if [ "$_CROSS_PROJ" = "true" ]; then
|
||||
{ _LE=$(~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>&1 >&3 3>&-); _LR=$?; } 3>&1
|
||||
else
|
||||
{ _LE=$(~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>&1 >&3 3>&-); _LR=$?; } 3>&1
|
||||
fi
|
||||
[ "$_LR" = 0 ] || { _LE=${_LE%%$'\n'*}; echo "LEARNINGS: unavailable (${_LE:-exit $_LR})"; }
|
||||
```
|
||||
|
||||
If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion:
|
||||
|
||||
> gstack can search learnings from your other projects on this machine to find
|
||||
> patterns that might apply here. This stays local (no data leaves your machine).
|
||||
> Recommended for solo developers. Skip if you work on multiple client codebases
|
||||
> where cross-contamination would be a concern.
|
||||
|
||||
Options:
|
||||
- A) Enable cross-project learnings (recommended)
|
||||
- B) Keep learnings project-scoped only
|
||||
|
||||
If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true`
|
||||
If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false`
|
||||
|
||||
Then re-run the search with the appropriate flag.
|
||||
|
||||
If learnings are found, incorporate them into your analysis. When a review finding
|
||||
matches a past learning, display:
|
||||
|
||||
**"Prior learning applied: [key] (confidence N/10, from [date])"**
|
||||
|
||||
This makes the compounding visible. The user should see that gstack is getting
|
||||
smarter on their codebase over time.
|
||||
Run Web research readiness before Step 4 and use it in the core review.
|
||||
|
||||
## Web research runs in Aside
|
||||
|
||||
@@ -488,6 +418,13 @@ CRITICAL is the blocking floor (docs/review-policy.md): a finding that would fai
|
||||
test, break production, or lose data or secrets. Everything else is INFORMATIONAL and
|
||||
never earns another pass. `NO FINDINGS` is a complete result from any reviewer.
|
||||
|
||||
**Blocker proof.** Every CRITICAL finding, yours or forwarded (specialist, QA,
|
||||
adversarial or bot), gets a floor reason (`fails a test`, `breaks production`,
|
||||
`loses data or secrets` or a forced-CRITICAL class below) and proof: a repro with
|
||||
its output, a failing test, or a cited `file:line` with its consequence. Verify a
|
||||
forwarded one and append both yourself: `Floor: <reason>. Proof: <repro | test |
|
||||
file:line -> consequence>`. At Step 5 an unproven CRITICAL is INFORMATIONAL, whatever its `Blocks:` line.
|
||||
|
||||
**Sideways sweep (every finding; reads code OUTSIDE the diff).** Grep the finding's pattern at every other ingress, command and call site, Read each match and rate severity on the worst sibling; a new enum value, status, tier or type constant is traced through every consumer the same way (checklist: Sideways sweep). Data loss, a trust-boundary bypass or uncapped spend is forced CRITICAL: no lane, specialist, dedup or saved decision downgrades it. Keep findings anchored to changed code.
|
||||
|
||||
**Gate Integrity reads Step 3.5's listing.** Carry the `GATE_SUMMARY:` line and every
|
||||
@@ -579,6 +516,8 @@ Verify evidence first, then score every finding (1-10) and apply its display rul
|
||||
Force its confidence to 4-5: use 4 for appendix-only reporting, or 5 only when
|
||||
the finding belongs in the main report with the medium-confidence caveat below.
|
||||
Never invent speculative confidence 7+.
|
||||
4. **A CRITICAL finding also needs Step 4's Blocker proof:** its floor reason and a
|
||||
repro, a failing test or a cited line with its consequence. Without both, emit it as INFORMATIONAL.
|
||||
|
||||
| Score | Meaning | Display rule |
|
||||
|-------|---------|-------------|
|
||||
@@ -590,10 +529,10 @@ Verify evidence first, then score every finding (1-10) and apply its display rul
|
||||
|
||||
**Finding format:**
|
||||
|
||||
`[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description`
|
||||
`[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description`, and a CRITICAL line ends `Floor: <reason>. Proof: <...>`
|
||||
|
||||
Example:
|
||||
`[CRITICAL] (confidence: 9/10) user.rb:42 — SQL injection via string interpolation`
|
||||
`[CRITICAL] (confidence: 9/10) user.rb:42 — SQL injection via string interpolation. Floor: loses data or secrets. Proof: user.rb:42 puts params[:q] into where() -> any visitor reads every row`
|
||||
|
||||
**Calibration learning:** If the user confirms a reported finding scored < 7 is
|
||||
real, log it per Operational Self-Improvement.
|
||||
@@ -610,6 +549,12 @@ but its doc was not updated, flag an INFORMATIONAL finding naming the file and
|
||||
affected behavior. Propose `/document-release` for the parent's decision, never a
|
||||
critical finding or another writer during collection. Skip silently if no docs exist.
|
||||
|
||||
**Dispatch markers (these override the section).** Step 4.5 prints one line starting
|
||||
`Dispatching N specialists:`, small diffs too: `Dispatching 0 specialists: (small diff)`
|
||||
or `Dispatching 1 specialists: State Machines (small diff)`, never the section's
|
||||
`Small diff` line. Step 4.8 prints `Dispatching adversarial pass: native` right before
|
||||
its native Agent call.
|
||||
|
||||
---
|
||||
|
||||
> **STOP.** Before dispatching the Review Army specialists and merging their findings after the critical pass (Step 4.5), Read `~/.claude/skills/gstack/review/sections/review-army.md` and execute it
|
||||
@@ -947,33 +892,6 @@ Emit one final report merging all reviewers:
|
||||
- `Gate edits: UNAVAILABLE — <GATE_ERROR line or helper exit>` (missing coverage, never `none detected`)
|
||||
List each kept finding's `gate`, path and `reason` under it.
|
||||
|
||||
## Capture Learnings
|
||||
|
||||
If you discovered a non-obvious pattern, pitfall, or architectural insight during
|
||||
this session, log it for future sessions:
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"review","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}'
|
||||
```
|
||||
|
||||
**Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference`
|
||||
(user stated), `architecture` (structural decision), `tool` (library/framework insight),
|
||||
`operational` (project environment/CLI/workflow knowledge).
|
||||
|
||||
**Sources:** `observed` (you found this in the code), `user-stated` (user told you),
|
||||
`inferred` (AI deduction), `cross-model` (both Claude and Codex agree).
|
||||
|
||||
**Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9.
|
||||
An inference you're not sure about is 4-5. A user preference they explicitly stated is 10.
|
||||
|
||||
**files:** Include the specific file paths this learning references. This enables
|
||||
staleness detection: if those files are later deleted, the learning can be flagged.
|
||||
|
||||
**Only log genuine discoveries.** Don't log obvious things. Don't log things the user
|
||||
already knows. A good test: would this insight save time in a future session? If yes, log it.
|
||||
|
||||
If the review exits early (e.g. no diff against the base), do **not** write this entry.
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **Read the FULL diff before commenting.** Do not flag issues already addressed in the diff.
|
||||
|
||||
+14
-30
@@ -126,28 +126,6 @@ Keep the review-record terms separate:
|
||||
| `review_binding` | The logger's proof tying a finished review to its captured candidate, not a finding identifier. |
|
||||
| `snapshot_covered_paths` | Supporting advice files the logger proved byte-identical to that candidate. Used by the prior-Skip checker, never supplied by the reviewer. |
|
||||
|
||||
## Step 3.4: Workspace-aware queue status (advisory)
|
||||
|
||||
Check the claimed VERSION's queue slot. This landing-order advice never blocks review.
|
||||
|
||||
```bash
|
||||
BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
BASE_BRANCH="<base>"
|
||||
BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version \
|
||||
--base "$BASE_BRANCH" \
|
||||
--bump patch \
|
||||
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
CLAIMED_COUNT=$(echo "$QUEUE_JSON" | jq -r '.claimed | length // 0')
|
||||
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
|
||||
```
|
||||
|
||||
- `OFFLINE=true`: skip this section.
|
||||
- Otherwise, include ONE line in the review output: `Version claimed: v<BRANCH_VERSION>. Queue: <CLAIMED_COUNT> PR(s) ahead. <VERDICT>` where VERDICT is `Slot free` (`BRANCH_VERSION >= NEXT_SLOT`) or `⚠ queue moved — rerun /ship to reconcile v<BRANCH_VERSION> → v<NEXT_SLOT>`.
|
||||
|
||||
Compare versions numerically per dotted component; missing ones count as zero.
|
||||
|
||||
---
|
||||
|
||||
## Step 3.5: Diff scans
|
||||
@@ -177,10 +155,7 @@ Advisory findings only; skip if unavailable.
|
||||
|
||||
## Step 3.6: Gather review context
|
||||
|
||||
Run Prior Learnings, then Web research readiness, before Step 4; use both in
|
||||
the core review.
|
||||
|
||||
{{LEARNINGS_SEARCH}}
|
||||
Run Web research readiness before Step 4 and use it in the core review.
|
||||
|
||||
{{ASIDE_RESEARCH}}
|
||||
|
||||
@@ -193,6 +168,13 @@ CRITICAL is the blocking floor (docs/review-policy.md): a finding that would fai
|
||||
test, break production, or lose data or secrets. Everything else is INFORMATIONAL and
|
||||
never earns another pass. `NO FINDINGS` is a complete result from any reviewer.
|
||||
|
||||
**Blocker proof.** Every CRITICAL finding, yours or forwarded (specialist, QA,
|
||||
adversarial or bot), gets a floor reason (`fails a test`, `breaks production`,
|
||||
`loses data or secrets` or a forced-CRITICAL class below) and proof: a repro with
|
||||
its output, a failing test, or a cited `file:line` with its consequence. Verify a
|
||||
forwarded one and append both yourself: `Floor: <reason>. Proof: <repro | test |
|
||||
file:line -> consequence>`. At Step 5 an unproven CRITICAL is INFORMATIONAL, whatever its `Blocks:` line.
|
||||
|
||||
{{SIDEWAYS_SWEEP}}
|
||||
|
||||
**Gate Integrity reads Step 3.5's listing.** Carry the `GATE_SUMMARY:` line and every
|
||||
@@ -256,6 +238,12 @@ but its doc was not updated, flag an INFORMATIONAL finding naming the file and
|
||||
affected behavior. Propose `/document-release` for the parent's decision, never a
|
||||
critical finding or another writer during collection. Skip silently if no docs exist.
|
||||
|
||||
**Dispatch markers (these override the section).** Step 4.5 prints one line starting
|
||||
`Dispatching N specialists:`, small diffs too: `Dispatching 0 specialists: (small diff)`
|
||||
or `Dispatching 1 specialists: State Machines (small diff)`, never the section's
|
||||
`Small diff` line. Step 4.8 prints `Dispatching adversarial pass: native` right before
|
||||
its native Agent call.
|
||||
|
||||
---
|
||||
|
||||
{{SECTION:review-army}}
|
||||
@@ -491,10 +479,6 @@ Emit one final report merging all reviewers:
|
||||
- `Gate edits: UNAVAILABLE — <GATE_ERROR line or helper exit>` (missing coverage, never `none detected`)
|
||||
List each kept finding's `gate`, path and `reason` under it.
|
||||
|
||||
{{LEARNINGS_LOG}}
|
||||
|
||||
If the review exits early (e.g. no diff against the base), do **not** write this entry.
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **Read the FULL diff before commenting.** Do not flag issues already addressed in the diff.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* R56 cut ledger (GST-1088): the pieces cut from one skill's render in every
|
||||
* loadout and on every host. modules/cuts.json is the hand-written source;
|
||||
* test/cut-ledger.test.ts is its ratchet. Each cut is a skill-qualified
|
||||
* module id (`part:<skill>/<name>`) that `gstack-modules list` shows with
|
||||
* status cut. A cut with `preamble_piece` is applied by preambleSections()
|
||||
* for that skill alone; any other cut was removed from the skill's template,
|
||||
* and the ratchet keeps its `match` text out of every generated SKILL.md.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
export const CUT_LEDGER_FILE = 'modules/cuts.json';
|
||||
|
||||
export interface CutEntry {
|
||||
id: string;
|
||||
skill: string;
|
||||
status: 'cut';
|
||||
/** Where the cut text lived; restoring it starts here. */
|
||||
source: string;
|
||||
/** Text that marks the piece in a generated SKILL.md; the ratchet fails when it reappears. */
|
||||
match: string;
|
||||
/** PREAMBLE_PIECES id this cut removes from the skill's preamble. */
|
||||
preamble_piece?: string;
|
||||
why: string;
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
export interface CutLedger {
|
||||
version: 1;
|
||||
note: string;
|
||||
cuts: CutEntry[];
|
||||
/** Always-loaded bytes of <skill>/SKILL.md (frontmatter included) per targeted skill: an exact ratchet. */
|
||||
pins: Record<string, number>;
|
||||
/** Who approves a restore or a raised pin. */
|
||||
approver: string;
|
||||
}
|
||||
|
||||
let cached: CutLedger | undefined;
|
||||
|
||||
export function loadCutLedger(root = ROOT): CutLedger {
|
||||
if (root === ROOT && cached) return cached;
|
||||
const ledger = JSON.parse(fs.readFileSync(path.join(root, CUT_LEDGER_FILE), 'utf-8')) as CutLedger;
|
||||
if (root === ROOT) cached = ledger;
|
||||
return ledger;
|
||||
}
|
||||
|
||||
/** PREAMBLE_PIECES ids cut from this skill's preamble. */
|
||||
export function preambleCuts(skill: string): Set<string> {
|
||||
return new Set(loadCutLedger().cuts.filter(c => c.skill === skill && c.preamble_piece).map(c => c.preamble_piece!));
|
||||
}
|
||||
@@ -68,7 +68,7 @@ const JUDGES: Judge[] = [
|
||||
judgeGoal: 'how to review and explore changed behavior even for small diffs without a plan or server, preserve report-only discovery and the test_stub ASK gate, handle incomplete probes honestly, and rerun affected evidence after approved repairs',
|
||||
agentCapability: 'frontier' }, { files: [], text }),
|
||||
options: { max_tokens: DEFAULT_JUDGE_MAX_TOKENS },
|
||||
defect: text => cut(text, '## Step 3: Get the diff', '## Step 3.4'),
|
||||
defect: text => cut(text, '## Step 3: Get the diff', '## Step 3.5'),
|
||||
},
|
||||
{
|
||||
name: 'setup-deploy/SKILL.md platform setup',
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function modulesMain(args: string[]): Promise<CliResult> {
|
||||
return { ...e, source_bytes: bytes.length, source_sha256: crypto.createHash('sha256').update(bytes).digest('hex') };
|
||||
});
|
||||
if (rest.includes('--json')) return { code: 0, stdout: JSON.stringify({ loadouts: Object.keys(manifest.loadouts), modules: rows }, null, 2) + '\n', stderr: '' };
|
||||
const lines = rows.map(r => `${r.id.padEnd(48)} ${r.kind.padEnd(8)} ${r.pinned ? 'pinned' : ' '} ${r.source}`);
|
||||
const lines = rows.map(r => `${r.id.padEnd(48)} ${r.kind.padEnd(8)} ${r.pinned ? 'pinned' : r.status === 'cut' ? 'cut ' : ' '} ${r.source}`);
|
||||
return { code: 0, stdout: [`loadouts: ${Object.keys(manifest.loadouts).join(', ')}`, ...lines].join('\n') + '\n', stderr: '' };
|
||||
}
|
||||
if (command === 'validate') {
|
||||
|
||||
+13
-3
@@ -6,8 +6,10 @@
|
||||
* (`section:<skill>/<id>`, from <skill>/sections/manifest.json), one model
|
||||
* overlay (`overlay:<name>`, from model-overlays/) or one preamble piece
|
||||
* (`preamble:<id>`, from PREAMBLE_PIECES). The catalog is derived from those
|
||||
* sources; modules/loadouts.json is the only hand-written file and holds the
|
||||
* rungs, the model-id to rung table, the pinned ids and the loadouts.
|
||||
* sources; modules/loadouts.json holds the rungs, the model-id to rung table,
|
||||
* the pinned ids and the loadouts. modules/cuts.json adds one `part:<skill>/<name>`
|
||||
* entry per R56 cut: status cut, removed from that skill in every loadout, so
|
||||
* no loadout or spec may name it.
|
||||
*
|
||||
* Every error is a ModuleError with a stable code, a fix line and a
|
||||
* docs/troubleshooting.md anchor. Nothing here ever falls back to `full` on
|
||||
@@ -19,6 +21,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { discoverTemplates } from './discover-skills';
|
||||
import { PREAMBLE_MODULE_IDS } from './resolvers/preamble';
|
||||
import { CUT_LEDGER_FILE, loadCutLedger } from './cut-ledger';
|
||||
import type { ModuleFilter } from './resolvers/types';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -30,13 +33,15 @@ export const FULL = 'full';
|
||||
export const IMPLICIT_PINS = ['skill:gstack', 'skill:gstack-upgrade', 'preamble:bash'];
|
||||
const NAME_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
export type ModuleKind = 'skill' | 'section' | 'overlay' | 'preamble';
|
||||
export type ModuleKind = 'skill' | 'section' | 'overlay' | 'preamble' | 'part';
|
||||
export interface ModuleEntry {
|
||||
id: string;
|
||||
kind: ModuleKind;
|
||||
/** Repository-relative source file. */
|
||||
source: string;
|
||||
pinned: boolean;
|
||||
/** A `part` entry from modules/cuts.json. */
|
||||
status?: 'cut';
|
||||
}
|
||||
|
||||
export interface LoadoutDef {
|
||||
@@ -89,6 +94,7 @@ export function loadLoadouts(root = ROOT): LoadoutsManifest {
|
||||
export function moduleCatalog(manifest: LoadoutsManifest, root = ROOT): ModuleEntry[] {
|
||||
const pinned = new Set([...manifest.pinned, ...IMPLICIT_PINS]);
|
||||
const entries: Omit<ModuleEntry, 'pinned'>[] = [];
|
||||
const cuts = loadCutLedger(root).cuts;
|
||||
for (const { tmpl } of discoverTemplates(root)) {
|
||||
const dir = path.dirname(tmpl);
|
||||
entries.push({ id: `skill:${dir === '.' ? 'gstack' : dir}`, kind: 'skill', source: tmpl });
|
||||
@@ -101,6 +107,7 @@ export function moduleCatalog(manifest: LoadoutsManifest, root = ROOT): ModuleEn
|
||||
entries.push({ id: `overlay:${file.slice(0, -3)}`, kind: 'overlay', source: `model-overlays/${file}` });
|
||||
}
|
||||
for (const id of PREAMBLE_MODULE_IDS) entries.push({ id, kind: 'preamble', source: 'scripts/resolvers/preamble.ts' });
|
||||
for (const cut of cuts) entries.push({ id: cut.id, kind: 'part', source: cut.source, status: 'cut' });
|
||||
return entries.map(e => ({ ...e, pinned: pinned.has(e.id) }));
|
||||
}
|
||||
|
||||
@@ -134,6 +141,7 @@ export function rungFor(manifest: LoadoutsManifest, raw: string): Rung {
|
||||
export function validateLoadouts(manifest: LoadoutsManifest, catalog: ModuleEntry[], corpus: string[] = []): string[] {
|
||||
const problems: string[] = [];
|
||||
const ids = new Set(catalog.map(e => e.id));
|
||||
const cut = new Set(catalog.filter(e => e.status === 'cut').map(e => e.id));
|
||||
const pinned = new Set([...(manifest.pinned ?? []), ...IMPLICIT_PINS]);
|
||||
if (manifest.version !== 1) problems.push(`version must be 1 (got ${clamp(String(manifest.version))})`);
|
||||
for (const rung of RUNGS) if (!Array.isArray(manifest.rungs?.[rung])) problems.push(`rungs.${rung} must be a list of model-id patterns`);
|
||||
@@ -149,6 +157,7 @@ export function validateLoadouts(manifest: LoadoutsManifest, catalog: ModuleEntr
|
||||
if (def.base !== undefined && !has(def.base)) problems.push(`loadouts.${name}.base names no loadout (${clamp(def.base)})`);
|
||||
for (const id of [...(def.exclude ?? []), ...(Array.isArray(def.include) ? def.include : [])]) {
|
||||
if (!ids.has(id)) problems.push(`loadouts.${name} names ${clamp(id)}, which is not a module`);
|
||||
else if (cut.has(id)) problems.push(`loadouts.${name} names ${id}, which is cut in every loadout (${CUT_LEDGER_FILE})`);
|
||||
}
|
||||
for (const id of def.exclude ?? []) {
|
||||
if (pinned.has(id)) problems.push(`loadouts.${name} excludes pinned ${id}`);
|
||||
@@ -230,6 +239,7 @@ export function selectModules(input: { loadout?: string; modules?: string }, roo
|
||||
}
|
||||
const entry = ids.get(id);
|
||||
if (!entry) throw new ModuleError('MODULE_UNKNOWN', `${clamp(id)} is not a module`, 'gstack-modules list');
|
||||
if (entry.status === 'cut') throw new ModuleError('MODULE_SPEC_INVALID', `${id} is cut in every loadout (${CUT_LEDGER_FILE})`, `drop ${edit} from the spec; restoring a cut is a ${CUT_LEDGER_FILE} change`);
|
||||
if (op === '-' && entry.pinned) {
|
||||
throw new ModuleError('MODULE_PINNED', `${id} is pinned and can never be excluded`, `drop -${id} from the spec (pinned ids: gstack-modules list --pinned)`);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ Verify evidence first, then score every finding (1-10) and apply its display rul
|
||||
Force its confidence to 4-5: use 4 for appendix-only reporting, or 5 only when
|
||||
the finding belongs in the main report with the medium-confidence caveat below.
|
||||
Never invent speculative confidence 7+.
|
||||
4. **A CRITICAL finding also needs Step 4's Blocker proof:** its floor reason and a
|
||||
repro, a failing test or a cited line with its consequence. Without both, emit it as INFORMATIONAL.
|
||||
|
||||
| Score | Meaning | Display rule |
|
||||
|-------|---------|-------------|
|
||||
@@ -43,10 +45,10 @@ Verify evidence first, then score every finding (1-10) and apply its display rul
|
||||
|
||||
**Finding format:**
|
||||
|
||||
\`[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description\`
|
||||
\`[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description\`, and a CRITICAL line ends \`Floor: <reason>. Proof: <...>\`
|
||||
|
||||
Example:
|
||||
\`[CRITICAL] (confidence: 9/10) user.rb:42 — SQL injection via string interpolation\`
|
||||
\`[CRITICAL] (confidence: 9/10) user.rb:42 — SQL injection via string interpolation. Floor: loses data or secrets. Proof: user.rb:42 puts params[:q] into where() -> any visitor reads every row\`
|
||||
|
||||
**Calibration learning:** If the user confirms a reported finding scored < 7 is
|
||||
real, log it per Operational Self-Improvement.`;
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* the pre-hoist oracle. {{PREAMBLE}} resolves through ./preamble-hoist.ts,
|
||||
* which renders tier 1 inline and splits tier 2+ into a per-skill pointer plus
|
||||
* the shared sections/preamble.md the skill Reads on load. Keep composition
|
||||
* declarative — no inline logic beyond tier gating.
|
||||
* declarative — no inline logic beyond tier gating and the skill's cuts in
|
||||
* modules/cuts.json (scripts/cut-ledger.ts).
|
||||
*
|
||||
* Each skill runs independently via `claude -p` (or the host's equivalent).
|
||||
* The preamble provides: update checks, session
|
||||
@@ -23,6 +24,7 @@
|
||||
import type { TemplateContext } from './types';
|
||||
import { generateModelOverlay } from './model-overlay';
|
||||
import { generateQuestionTuning } from './question-tuning';
|
||||
import { preambleCuts } from '../cut-ledger';
|
||||
|
||||
// Core bootstrap
|
||||
import { generatePreambleBash } from './preamble/generate-preamble-bash';
|
||||
@@ -126,13 +128,14 @@ export function preambleSections(ctx: TemplateContext): string[] {
|
||||
throw new Error(`Invalid preamble-tier: ${tier} in ${ctx.tmplPath}. Must be 1-4.`);
|
||||
}
|
||||
const filter = ctx.modules;
|
||||
const cut = preambleCuts(ctx.skillName);
|
||||
const pieceTier = (piece: PreamblePiece): number => {
|
||||
if (!filter || piece.id === null || filter.pinned.has(`preamble:${piece.id}`)) return tier;
|
||||
return Math.min(tier, filter.preambleTierMax ?? tier);
|
||||
};
|
||||
return PREAMBLE_PIECES
|
||||
.filter(piece => (piece.when?.(ctx) ?? true)
|
||||
&& !(piece.id && filter?.excluded.has(`preamble:${piece.id}`))
|
||||
&& !(piece.id && (filter?.excluded.has(`preamble:${piece.id}`) || cut.has(piece.id)))
|
||||
&& pieceTier(piece) >= piece.minTier)
|
||||
.map(piece => piece.render(ctx, pieceTier(piece)))
|
||||
.filter(s => s && s.trim().length > 0);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* R56 cut ledger ratchet (GST-1088 lane C). modules/cuts.json names the pieces
|
||||
* cut from /investigate and /review in every loadout and on every host, and pins
|
||||
* each targeted skill's always-loaded bytes (its Claude SKILL.md, frontmatter
|
||||
* included). This test fails when a cut piece reappears in a generated SKILL.md,
|
||||
* when a pin moves, or when a cut or a review-only change leaks into another skill.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { ALL_HOST_CONFIGS } from '../hosts/index';
|
||||
import { CUT_LEDGER_FILE, loadCutLedger, preambleCuts, type CutLedger } from '../scripts/cut-ledger';
|
||||
import { PREAMBLE_PIECES } from '../scripts/resolvers/preamble';
|
||||
import { SHARED_PREAMBLE_FILE } from '../scripts/resolvers/preamble-hoist';
|
||||
import { discoverTemplates } from '../scripts/discover-skills';
|
||||
import { loadLoadouts, moduleCatalog, selectModules, validateLoadouts } from '../scripts/modules';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const TARGETED = ['investigate', 'review'];
|
||||
/** Text only /review may render: the GST-1088 markers and the blocker-proof rule. */
|
||||
const REVIEW_ONLY = ['Dispatching 0 specialists: (small diff)', 'Dispatching adversarial pass: native', 'Blocker proof'];
|
||||
|
||||
const skillOfDir = (dir: string) => (dir === '.' ? 'gstack' : dir.replace(/^gstack-/, ''));
|
||||
|
||||
/** Every violation of the ledger by these renders (key "host/skill") and Claude SKILL.md byte counts. */
|
||||
export function cutViolations(renders: Record<string, string>, bytes: Record<string, number>, ledger: CutLedger): string[] {
|
||||
const out: string[] = [];
|
||||
for (const [key, text] of Object.entries(renders).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const [host, skill] = key.split('/');
|
||||
for (const cut of ledger.cuts.filter(c => c.skill === skill && text.includes(c.match))) {
|
||||
out.push(`${host}/${skill}/SKILL.md: cut section "${cut.match}" reappeared (${CUT_LEDGER_FILE} ${cut.id}: ${cut.why}). `
|
||||
+ `Fix: remove it from ${cut.source}, then bun run gen:skill-docs. Restoring it instead needs a measurement in the PR and ${ledger.approver}.`);
|
||||
}
|
||||
}
|
||||
for (const [skill, pin] of Object.entries(ledger.pins).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const actual = bytes[skill];
|
||||
if (actual === undefined) { out.push(`pins.${skill} in ${CUT_LEDGER_FILE} names no ${skill}/SKILL.md. Delete the pin.`); continue; }
|
||||
if (actual > pin) {
|
||||
out.push(`${skill}/SKILL.md: ${actual} always-loaded bytes, pinned at ${pin} (+${actual - pin}). `
|
||||
+ `Fix: edit ${skill}/SKILL.md.tmpl, then bun run gen:skill-docs. Re-pin: set pins.${skill} to ${actual} in ${CUT_LEDGER_FILE} with the measurement in the PR body; approver: ${ledger.approver}.`);
|
||||
} else if (actual < pin) {
|
||||
out.push(`${skill}/SKILL.md: ${actual} always-loaded bytes, pinned at ${pin} (-${pin - actual}). Re-pin: lower pins.${skill} to ${actual} in ${CUT_LEDGER_FILE}.`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Each skill's SKILL.md plus its own section files (the shared preamble is the same file for every skill). */
|
||||
function renderAllHosts(outDir: string): Record<string, string> {
|
||||
const render = Bun.spawnSync(
|
||||
['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--out-dir', outDir],
|
||||
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 180_000 },
|
||||
);
|
||||
if (render.exitCode !== 0) throw new Error(`gen-skill-docs --host all --out-dir failed (exit ${render.exitCode}):\n${render.stderr.toString()}`);
|
||||
const renders: Record<string, string> = {};
|
||||
for (const host of ALL_HOST_CONFIGS) {
|
||||
const skillsRoot = host.name === 'claude' ? ROOT : path.join(outDir, host.hostSubdir, 'skills');
|
||||
const dirs = fs.readdirSync(skillsRoot, { withFileTypes: true }).filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
|
||||
if (host.name === 'claude') dirs.push('.');
|
||||
for (const dir of dirs) {
|
||||
const file = path.join(skillsRoot, dir, 'SKILL.md');
|
||||
if (!fs.existsSync(file) || (host.name === 'claude' && !fs.existsSync(`${file}.tmpl`))) continue;
|
||||
const sectionsDir = path.join(skillsRoot, dir, 'sections');
|
||||
const sections = dir !== '.' && fs.existsSync(sectionsDir)
|
||||
? fs.readdirSync(sectionsDir).filter(f => f.endsWith('.md') && f !== SHARED_PREAMBLE_FILE).sort().map(f => fs.readFileSync(path.join(sectionsDir, f), 'utf-8'))
|
||||
: [];
|
||||
renders[`${host.name}/${skillOfDir(dir)}`] = [fs.readFileSync(file, 'utf-8'), ...sections].join('\n');
|
||||
}
|
||||
}
|
||||
return renders;
|
||||
}
|
||||
|
||||
const ledger = loadCutLedger();
|
||||
const claudeBytes = Object.fromEntries(TARGETED.map(s => [s, fs.statSync(path.join(ROOT, s, 'SKILL.md')).size]));
|
||||
|
||||
describe('cut ledger ratchet messages', () => {
|
||||
const fixture: CutLedger = {
|
||||
version: 1, note: '', approver: 'the owner',
|
||||
cuts: [{ id: 'part:x/learn', skill: 'x', status: 'cut', source: 'x/SKILL.md.tmpl', match: '## Learn', why: 'unused', evidence: 'run 1' }],
|
||||
pins: { x: 100, y: 50 },
|
||||
};
|
||||
test('names the file, the section and its entry, the bytes and the fix or re-pin with its approver', () => {
|
||||
expect(cutViolations({ 'codex/x': 'a\n## Learn\n', 'codex/z': '## Learn' }, { x: 101, y: 49 }, fixture)).toEqual([
|
||||
`codex/x/SKILL.md: cut section "## Learn" reappeared (${CUT_LEDGER_FILE} part:x/learn: unused). Fix: remove it from x/SKILL.md.tmpl, then bun run gen:skill-docs. Restoring it instead needs a measurement in the PR and the owner.`,
|
||||
`x/SKILL.md: 101 always-loaded bytes, pinned at 100 (+1). Fix: edit x/SKILL.md.tmpl, then bun run gen:skill-docs. Re-pin: set pins.x to 101 in ${CUT_LEDGER_FILE} with the measurement in the PR body; approver: the owner.`,
|
||||
`y/SKILL.md: 49 always-loaded bytes, pinned at 50 (-1). Re-pin: lower pins.y to 49 in ${CUT_LEDGER_FILE}.`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modules/cuts.json', () => {
|
||||
test('cuts and pins cover exactly /investigate and /review', () => {
|
||||
expect([...new Set(ledger.cuts.map(c => c.skill))].sort()).toEqual(TARGETED);
|
||||
expect(Object.keys(ledger.pins).sort()).toEqual(TARGETED);
|
||||
});
|
||||
|
||||
test('each cut is a skill-qualified id with a real source, preamble piece and match', () => {
|
||||
const pieces = new Set(PREAMBLE_PIECES.map(p => p.id));
|
||||
const others = discoverTemplates(ROOT).map(t => path.join(ROOT, path.dirname(t.tmpl), 'SKILL.md'))
|
||||
.filter(f => !TARGETED.some(s => f.endsWith(`/${s}/SKILL.md`)) && fs.existsSync(f)).map(f => fs.readFileSync(f, 'utf-8'));
|
||||
expect(new Set(ledger.cuts.map(c => c.id)).size).toBe(ledger.cuts.length);
|
||||
for (const cut of ledger.cuts) {
|
||||
expect(cut.id).toMatch(new RegExp(`^part:${cut.skill}/[a-z0-9-]+$`));
|
||||
expect(cut.status).toBe('cut');
|
||||
expect(fs.existsSync(path.join(ROOT, cut.source)), `${cut.id} source ${cut.source}`).toBe(true);
|
||||
if (cut.preamble_piece) expect(pieces.has(cut.preamble_piece), `${cut.id} preamble_piece`).toBe(true);
|
||||
expect(others.some(text => text.includes(cut.match)), `${cut.id} match "${cut.match}" appears in no other skill, so it guards nothing`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('a preamble cut applies to its own skill alone', () => {
|
||||
for (const { tmpl } of discoverTemplates(ROOT)) {
|
||||
const skill = skillOfDir(path.dirname(tmpl));
|
||||
expect([...preambleCuts(skill)]).toEqual(ledger.cuts.filter(c => c.skill === skill && c.preamble_piece).map(c => c.preamble_piece!));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('module catalog lists cuts', () => {
|
||||
const manifest = loadLoadouts();
|
||||
test('every cut id is a catalog entry with status cut', () => {
|
||||
const catalog = moduleCatalog(manifest);
|
||||
for (const cut of ledger.cuts) expect(catalog.find(e => e.id === cut.id)).toMatchObject({ kind: 'part', status: 'cut', pinned: false });
|
||||
});
|
||||
test('a loadout or a --modules spec cannot name a cut, since it is cut in every loadout', () => {
|
||||
const id = ledger.cuts[0].id;
|
||||
const bad = { ...manifest, loadouts: { ...manifest.loadouts, odd: { base: 'full', include: [id] } } };
|
||||
expect(validateLoadouts(bad, moduleCatalog(manifest))).toContain(`loadouts.odd names ${id}, which is cut in every loadout (${CUT_LEDGER_FILE})`);
|
||||
expect(() => selectModules({ modules: `full,+${id}` })).toThrow(/cut in every loadout/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cut ledger against every host render', () => {
|
||||
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cut-ledger-'));
|
||||
afterAll(() => fs.rmSync(outDir, { recursive: true, force: true }));
|
||||
const renders = renderAllHosts(outDir);
|
||||
|
||||
test('no cut piece reappears in its skill or its sections, and each pin matches its SKILL.md', () => {
|
||||
for (const host of ALL_HOST_CONFIGS) for (const skill of TARGETED) expect(renders[`${host.name}/${skill}`], `${host.name}/${skill}`).toBeDefined();
|
||||
const violations = cutViolations(renders, claudeBytes, ledger);
|
||||
if (violations.length) throw new Error(`${violations.length} cut ledger violation(s):\n ${violations.join('\n ')}`);
|
||||
});
|
||||
|
||||
test('a preamble cut never comes back through the shared preamble file, on any host', () => {
|
||||
const leaks: string[] = [];
|
||||
for (const host of ALL_HOST_CONFIGS) for (const cut of ledger.cuts.filter(c => c.preamble_piece)) {
|
||||
const file = host.name === 'claude'
|
||||
? path.join(ROOT, cut.skill, 'sections', SHARED_PREAMBLE_FILE)
|
||||
: path.join(outDir, host.hostSubdir, 'skills', `gstack-${cut.skill}`, 'sections', SHARED_PREAMBLE_FILE);
|
||||
if (fs.existsSync(file) && fs.readFileSync(file, 'utf-8').includes(cut.match)) leaks.push(`${host.name}: ${cut.id} "${cut.match}" in ${path.relative(outDir, file)}`);
|
||||
}
|
||||
expect(leaks).toEqual([]);
|
||||
});
|
||||
|
||||
test('review-only GST-1088 text renders in /review alone, on every host', () => {
|
||||
const leaks = Object.entries(renders).filter(([key]) => !key.endsWith('/review'))
|
||||
.flatMap(([key, text]) => REVIEW_ONLY.filter(s => text.includes(s)).map(s => `${key}: "${s}"`));
|
||||
expect(leaks).toEqual([]);
|
||||
expect(REVIEW_ONLY.filter(s => !renders['claude/review'].includes(s))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/** Every file under dir (relative path to bytes), skipping the targeted skills' own trees. */
|
||||
function readOtherSkills(dir: string): Map<string, Buffer> {
|
||||
const out = new Map<string, Buffer>();
|
||||
for (const rel of fs.readdirSync(dir, { recursive: true, withFileTypes: false }) as string[]) {
|
||||
const file = path.join(dir, rel);
|
||||
if (fs.statSync(file).isFile()) out.set(rel, fs.readFileSync(file));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const targetedPath = new RegExp(`(^|/)(gstack-)?(${TARGETED.join('|')})/`);
|
||||
|
||||
describe('the ledger reaches only /investigate and /review', () => {
|
||||
test('every other skill renders byte-identical on every host with modules/cuts.json and with an empty ledger', () => {
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cut-scope-'));
|
||||
try {
|
||||
const copy = path.join(work, 'checkout');
|
||||
const listed = Bun.spawnSync(['git', 'ls-files', '-z'], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 60_000 });
|
||||
if (listed.exitCode !== 0) throw new Error(`git ls-files failed: ${listed.stderr.toString()}`);
|
||||
for (const rel of listed.stdout.toString().split('\0').filter(Boolean)) {
|
||||
const source = path.join(ROOT, rel);
|
||||
if (!fs.existsSync(source) || !fs.lstatSync(source).isFile()) continue;
|
||||
fs.mkdirSync(path.dirname(path.join(copy, rel)), { recursive: true });
|
||||
fs.copyFileSync(source, path.join(copy, rel));
|
||||
}
|
||||
fs.symlinkSync(path.join(ROOT, 'node_modules'), path.join(copy, 'node_modules'));
|
||||
fs.writeFileSync(path.join(copy, CUT_LEDGER_FILE), JSON.stringify({ ...ledger, cuts: [], pins: {} }, null, 2) + '\n');
|
||||
|
||||
const out = path.join(work, 'out');
|
||||
const [withCuts, empty] = [ROOT, copy].map(cwd => {
|
||||
const render = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--out-dir', out],
|
||||
{ cwd, stdout: 'pipe', stderr: 'pipe', timeout: 180_000 });
|
||||
if (render.exitCode !== 0) throw new Error(`gen-skill-docs in ${cwd} failed (exit ${render.exitCode}):\n${render.stderr.toString()}`);
|
||||
const tree = readOtherSkills(out);
|
||||
fs.rmSync(out, { recursive: true, force: true });
|
||||
return tree;
|
||||
});
|
||||
|
||||
expect(withCuts.get('review/SKILL.md')?.equals(empty.get('review/SKILL.md')!), 'the empty ledger must change /review, or this test proves nothing').toBe(false);
|
||||
const differs = [...new Set([...withCuts.keys(), ...empty.keys()])].filter(rel => !targetedPath.test(rel)).sort()
|
||||
.filter(rel => !withCuts.get(rel)?.equals(empty.get(rel) ?? Buffer.alloc(0)) || !empty.has(rel));
|
||||
expect(differs, `${CUT_LEDGER_FILE} changed files outside /investigate and /review`).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
}, 600_000);
|
||||
});
|
||||
@@ -326,6 +326,7 @@ describe('parity with the measured prototype', () => {
|
||||
/** Files the shipped function reaches beyond the prototype, each with the reviewed edge class behind it. */
|
||||
const REVIEWED_SUPERSET: Record<string, string> = {
|
||||
setup: './setup, $ROOT/setup and join(..., "setup") exec edges (plan A2-2); setup is a full-gate file, so the closure stops there',
|
||||
'scripts/cut-ledger.ts': 'GST-1088: scripts/resolvers/preamble.ts imports it (the R56 cut ledger), so it reaches every consumer of the preamble',
|
||||
'lib/bun-isolation.ts': 'GSTA-56: every bun launcher (script-invocation, restamp, qa-deadline, the hooks) imports it, so it reaches every consumer of those launchers',
|
||||
'lib/ship-receipt-docs.ts': 'bin/gstack-ship-receipt imports it (the docs audit record and doc caps), so it reaches every consumer of the receipt binary',
|
||||
'lib/ship-receipt-prose.ts': 'bin/gstack-ship-receipt imports it (read --check-prose), so it reaches every consumer of the receipt binary',
|
||||
|
||||
-1
@@ -67,7 +67,6 @@
|
||||
"## Step 2: Read the checklist",
|
||||
"## Step 2.5: Check for review bot comments",
|
||||
"## Step 3: Get the diff",
|
||||
"## Step 3.4: Workspace-aware queue status (advisory)",
|
||||
"## Step 3.5: Diff scans",
|
||||
"## Step 3.6: Gather review context",
|
||||
"## Step 4: Critical pass (core review)",
|
||||
|
||||
+16
-11
@@ -10,9 +10,15 @@ import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runCapturedCommand } from './helpers/sync-command-capture';
|
||||
import { between, expectAbsent, expectMentions, expectOrdered, expectTokens } from './helpers/prompt-structure';
|
||||
import { loadCutLedger } from '../scripts/cut-ledger';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const MAX_SKILL_DESCRIPTION_LENGTH = 1024;
|
||||
/** GST-1088: R56 cuts (modules/cuts.json) remove learnings blocks from /investigate and /review; their absence is test/cut-ledger.test.ts's job. */
|
||||
const CUT_IDS = new Set(loadCutLedger().cuts.map(c => c.id));
|
||||
const notCut = (skill: string, part: string) => !CUT_IDS.has(`part:${skill}/${part}`);
|
||||
const SEARCH_SAMPLE = notCut('review', 'learnings-search') ? 'review' : 'office-hours';
|
||||
const LOG_SAMPLE = notCut('review', 'capture-learnings') ? 'review' : 'retro';
|
||||
|
||||
// Carved-skill aware (v2 plan T9): ship is now a skeleton SKILL.md + sections/*.md.
|
||||
// Read the union so assertions about content that MOVED into a section still pass.
|
||||
@@ -375,8 +381,7 @@ describe('gen-skill-docs', () => {
|
||||
});
|
||||
|
||||
test('generated SKILL.md with LEARNINGS_LOG contains operational type', () => {
|
||||
// Check a skill that has LEARNINGS_LOG (e.g., review)
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, LOG_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('operational');
|
||||
});
|
||||
|
||||
@@ -3456,7 +3461,7 @@ describe('Factory generation (--host factory)', () => {
|
||||
});
|
||||
|
||||
test('Factory preamble uses .factory paths', () => {
|
||||
const content = fs.readFileSync(path.join(FACTORY_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(FACTORY_DIR, `gstack-${SEARCH_SAMPLE}`, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('GSTACK_ROOT');
|
||||
expect(content).toContain('_r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack');
|
||||
expect(content).toContain('$GSTACK_BIN/gstack-config');
|
||||
@@ -4302,7 +4307,7 @@ describe('codex commands must not use inline $(git rev-parse --show-toplevel) fo
|
||||
// ─── Learnings + Confidence Resolver Tests ─────────────────────
|
||||
|
||||
describe('LEARNINGS_SEARCH resolver', () => {
|
||||
const SEARCH_SKILLS = ['review', 'ship', 'plan-eng-review', 'investigate', 'office-hours', 'plan-ceo-review'];
|
||||
const SEARCH_SKILLS = ['review', 'ship', 'plan-eng-review', 'investigate', 'office-hours', 'plan-ceo-review'].filter(s => notCut(s, 'learnings-search'));
|
||||
|
||||
for (const skill of SEARCH_SKILLS) {
|
||||
test(`${skill} generated SKILL.md contains learnings search`, () => {
|
||||
@@ -4313,13 +4318,13 @@ describe('LEARNINGS_SEARCH resolver', () => {
|
||||
}
|
||||
|
||||
test('learnings search includes cross-project config check', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, SEARCH_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('cross_project_learnings');
|
||||
expect(content).toContain('--cross-project');
|
||||
});
|
||||
|
||||
test('learnings search includes AskUserQuestion for first-time cross-project opt-in', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, SEARCH_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Enable cross-project learnings');
|
||||
expect(content).toContain('project-scoped only');
|
||||
});
|
||||
@@ -4349,13 +4354,13 @@ describe('LEARNINGS_SEARCH resolver', () => {
|
||||
});
|
||||
|
||||
test('learnings search mentions prior learning applied display format', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, SEARCH_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Prior learning applied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LEARNINGS_LOG resolver', () => {
|
||||
const LOG_SKILLS = ['review', 'retro', 'investigate'];
|
||||
const LOG_SKILLS = ['review', 'retro', 'investigate'].filter(s => notCut(s, 'capture-learnings'));
|
||||
|
||||
for (const skill of LOG_SKILLS) {
|
||||
test(`${skill} generated SKILL.md contains learnings log`, () => {
|
||||
@@ -4366,21 +4371,21 @@ describe('LEARNINGS_LOG resolver', () => {
|
||||
}
|
||||
|
||||
test('learnings log documents all type values', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, LOG_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
for (const type of ['pattern', 'pitfall', 'preference', 'architecture', 'tool']) {
|
||||
expect(content).toContain(type);
|
||||
}
|
||||
});
|
||||
|
||||
test('learnings log documents all source values', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, LOG_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
for (const source of ['observed', 'user-stated', 'inferred', 'cross-model']) {
|
||||
expect(content).toContain(source);
|
||||
}
|
||||
});
|
||||
|
||||
test('learnings log includes files field for staleness detection', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(ROOT, LOG_SAMPLE, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('"files"');
|
||||
expect(content).toContain('staleness detection');
|
||||
});
|
||||
|
||||
@@ -11,14 +11,18 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runGeneration } from '../scripts/gen-skill-docs';
|
||||
import { loadCutLedger } from '../scripts/cut-ledger';
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-learnings-callers-'));
|
||||
const out = path.join(tmp, 'render');
|
||||
const BUN_MISSING = 'gstack-learnings-search: bun not found on PATH, so learnings could not be read. Fix: install Bun (https://bun.sh), then re-run ./setup.';
|
||||
|
||||
const CALLERS: Array<{ name: string; file: (host: string) => string; hosts: string[] }> = [
|
||||
{ name: 'Prior Learnings (review)', file: h => (h === 'claude' ? 'review/SKILL.md' : '.agents/skills/gstack-review/SKILL.md'), hosts: ['claude', 'codex'] },
|
||||
{ name: 'investigate prior learnings', file: h => (h === 'claude' ? 'investigate/SKILL.md' : '.agents/skills/gstack-investigate/SKILL.md'), hosts: ['claude', 'codex'] },
|
||||
/** modules/cuts.json ids; a caller whose `cut` is listed must render no search fence at all. */
|
||||
const CUT = new Set(loadCutLedger().cuts.map(c => c.id));
|
||||
|
||||
const CALLERS: Array<{ name: string; file: (host: string) => string; hosts: string[]; cut?: string }> = [
|
||||
{ name: 'Prior Learnings (review)', file: h => (h === 'claude' ? 'review/SKILL.md' : '.agents/skills/gstack-review/SKILL.md'), hosts: ['claude', 'codex'], cut: 'part:review/learnings-search' },
|
||||
{ name: 'investigate prior learnings', file: h => (h === 'claude' ? 'investigate/SKILL.md' : '.agents/skills/gstack-investigate/SKILL.md'), hosts: ['claude', 'codex'], cut: 'part:investigate/learnings-search' },
|
||||
{ name: 'qa fix-loop search', file: h => (h === 'claude' ? 'qa/SKILL.md' : '.agents/skills/gstack-qa/SKILL.md'), hosts: ['claude', 'codex'] },
|
||||
{ name: 'ship adversarial search', file: h => (h === 'claude' ? 'ship/sections/adversarial.md' : '.agents/skills/gstack-ship/sections/adversarial.md'), hosts: ['claude', 'codex'] },
|
||||
{ name: 'review army specialist search', file: () => 'review/sections/review-army.md', hosts: ['claude'] },
|
||||
@@ -50,6 +54,10 @@ describe('B5: learnings callers report a failed search', () => {
|
||||
for (const host of caller.hosts) {
|
||||
test(`${host}: ${caller.name}`, () => {
|
||||
const fences = searchFences(caller.file(host));
|
||||
if (caller.cut && CUT.has(caller.cut)) {
|
||||
expect(fences).toEqual([]);
|
||||
return;
|
||||
}
|
||||
expect(fences.length).toBeGreaterThan(0);
|
||||
for (const fence of fences) {
|
||||
const missing = run(fence, host, `echo "${BUN_MISSING}" >&2\necho "second line" >&2\nexit 127`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { execSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { loadCutLedger } from '../scripts/cut-ledger';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin');
|
||||
@@ -137,8 +138,14 @@ describe('gstack-learnings-log', () => {
|
||||
// Caller contract: investigate/SKILL.md.tmpl must emit type:"investigation"
|
||||
// verbatim. Guards against the template drifting to an invalid type and
|
||||
// silently breaking the log path. See codex review finding for #1423.
|
||||
// While modules/cuts.json cuts part:investigate/investigation-log (GST-1088), the template
|
||||
// must carry no learnings log at all; restoring the cut restores the contract.
|
||||
test('investigate template emits type:"investigation" verbatim (caller contract)', () => {
|
||||
const tmpl = fs.readFileSync(path.join(ROOT, 'investigate/SKILL.md.tmpl'), 'utf-8');
|
||||
if (loadCutLedger().cuts.some(c => c.id === 'part:investigate/investigation-log')) {
|
||||
expect(tmpl).not.toContain('gstack-learnings-log');
|
||||
return;
|
||||
}
|
||||
// The invocation line must include "type":"investigation" exactly.
|
||||
expect(tmpl).toContain('"type":"investigation"');
|
||||
});
|
||||
|
||||
@@ -186,8 +186,8 @@ describe('safe classification (A1-2)', () => {
|
||||
describe('derived dependencies of this checkout', () => {
|
||||
test('a bin script a skill template invokes selects that skill\'s cases', () => {
|
||||
const consumers = real.e2e.get('bin/gstack-next-version') ?? new Set();
|
||||
expect(consumers.has('review-sql-injection')).toBe(true);
|
||||
expect(real.trace('review-sql-injection', 'bin/gstack-next-version')?.[0]).toBe('review/SKILL.md.tmpl');
|
||||
expect(consumers.has('ship-docsync-missing-marker')).toBe(true);
|
||||
expect(real.trace('ship-docsync-missing-marker', 'bin/gstack-next-version')?.[0]).toBe('ship/SKILL.md.tmpl');
|
||||
});
|
||||
|
||||
test('a compiled design source reaches exactly the cases whose closure names the design binary', () => {
|
||||
@@ -301,7 +301,7 @@ describe('deleted files (paths absent from the head tree)', () => {
|
||||
expect(result.mode).toBe('dependents');
|
||||
expect(result.e2e).toContain('review-sql-injection');
|
||||
expect(result.derivedFiles[0]?.file).toBe('bin/gstack-next-version');
|
||||
expect(real.referencers('bin/gstack-next-version')).toContain('review/SKILL.md.tmpl');
|
||||
expect(real.referencers('bin/gstack-next-version')).toContain('ship/SKILL.md.tmpl');
|
||||
});
|
||||
|
||||
test('a file the PR adds is read from the checkout under review, not from the selector\'s own tree (an older install)', () => {
|
||||
|
||||
@@ -222,11 +222,13 @@ describe('ship Step 9 instructions', () => {
|
||||
|
||||
describe('review Step 3 and Step 5.8 instructions', () => {
|
||||
const skill = fs.readFileSync(path.join(ROOT, 'review/SKILL.md'), 'utf8');
|
||||
const step3 = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.4:'));
|
||||
const step3 = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.5:'));
|
||||
const step58 = skill.slice(skill.indexOf('## Step 5.8: Persist Eng Review result'), skill.indexOf('### Report the final review'));
|
||||
const flat3 = step3.replace(/\s+/g, ' '), flat58 = step58.replace(/\s+/g, ' ');
|
||||
|
||||
test('the first pass starts full and only the pass after an edited pass narrows its DIFF_BASE, never Step 4.8 or other base uses', () => {
|
||||
expect(skill.indexOf('## Step 3.5:')).toBeGreaterThan(skill.indexOf('## Step 3: Get the diff'));
|
||||
expect(skill.indexOf('## Step 3: Get the diff')).toBeGreaterThanOrEqual(0);
|
||||
const block = step3.slice(step3.indexOf('```bash'), step3.indexOf('```', step3.indexOf('```bash') + 7));
|
||||
expect(block).toContain('gstack-review-log --start review\ngit diff "$DIFF_BASE"');
|
||||
expect(block).not.toContain('--fix-range');
|
||||
|
||||
@@ -109,7 +109,7 @@ test('review emits scope check after the plan audit and before the checklist', (
|
||||
test('review composes confidence-tagged findings into one final report with explicit incomplete coverage', () => {
|
||||
const flat = skill.replace(/\s+/g, ' ');
|
||||
expect(flat).toContain('CRITICAL/INFORMATIONAL');
|
||||
const report = flat.slice(flat.indexOf('### Report the final review'), flat.indexOf('{{LEARNINGS_LOG}}'));
|
||||
const report = flat.slice(flat.indexOf('### Report the final review'), flat.indexOf('## Important Rules'));
|
||||
expect(report).toMatch(/one final report/i);
|
||||
expect(report).toMatch(/unresolved non-advisory defects/i);
|
||||
expect(report).toMatch(/[Ss]tate INCOMPLETE if `COMPLETED` is false/);
|
||||
@@ -167,7 +167,7 @@ test('caller QA runs charter and setup after resource loading and has a severity
|
||||
test('review prepares context and deduplicates before classifying findings', () => {
|
||||
const positions = [
|
||||
'## Step 3.5: Diff scans', '## Step 3.6: Gather review context',
|
||||
'{{LEARNINGS_SEARCH}}', '{{ASIDE_RESEARCH}}', '## Step 4: Critical pass',
|
||||
'{{ASIDE_RESEARCH}}', '## Step 4: Critical pass',
|
||||
'## Step 5: Fix-First Review', '{{CROSS_REVIEW_DEDUP}}',
|
||||
'**Keep decisions through fix cycles:**', '### Step 5a: Classify each finding',
|
||||
].map(marker => skill.indexOf(marker));
|
||||
@@ -237,7 +237,7 @@ test('review section index follows the actual pre-fix execution order', () => {
|
||||
});
|
||||
|
||||
test('review finalization ownership: initialize invocation state and capture the core token before reading', () => {
|
||||
const start = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.4:'));
|
||||
const start = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.5:'));
|
||||
expect(start).toContain('CYCLES=0');
|
||||
expect(skill.match(/CYCLES=0/g)).toHaveLength(1);
|
||||
expect(start).toContain('gstack-review-log --start review\ngit diff "$DIFF_BASE"');
|
||||
@@ -350,7 +350,7 @@ test('review scope notes remain provisional until the plan section emits the onl
|
||||
// order, neither pins where the check is emitted; seam=none; no_claim=the judged clarity of the sentences.
|
||||
test('the final Scope Check is emitted once, in Step 3 after the full diff read, and every owner names that point', () => {
|
||||
const ctx: TemplateContext = { skillName: 'review', tmplPath: 'review/SKILL.md.tmpl', host: 'claude', paths: HOST_PATHS.claude };
|
||||
const step3 = between(skill, '## Step 3: Get the diff', '## Step 3.4:');
|
||||
const step3 = between(skill, '## Step 3: Get the diff', '## Step 3.5:');
|
||||
expectOrdered(step3, ['git diff "$DIFF_BASE"', "5. First pass only: emit Step 1.5's final Scope Check; `Diff read` records this read."], 'step3');
|
||||
expect(skill.split("emit Step 1.5's final Scope Check")).toHaveLength(2);
|
||||
const audit = generatePlanCompletionAuditReview(ctx);
|
||||
@@ -398,7 +398,7 @@ test('review confidence uses its severity labels without an undefined P0 excepti
|
||||
});
|
||||
|
||||
test('review names the lifecycle and record owners before using their persistence rules', () => {
|
||||
const start = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.4:')).replace(/\s+/g, ' ');
|
||||
const start = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.5:')).replace(/\s+/g, ' ');
|
||||
expect(start).toContain('REVIEW_START / PASS_START |');
|
||||
expect(start).toContain('`review_binding` |');
|
||||
expect(start).toContain('`snapshot_covered_paths` |');
|
||||
@@ -673,7 +673,7 @@ test('review persists gate identity fields, counts open findings and reports one
|
||||
['`open`', 'count', '`issues_found`'],
|
||||
['logger', 'stamps', '`actor`'],
|
||||
], 'review record');
|
||||
const report = compact(between(skill, '### Report the final review', '{{LEARNINGS_LOG}}'));
|
||||
const report = compact(between(skill, '### Report the final review', '## Important Rules'));
|
||||
expectOrdered(report, ['`Gate edits: none detected (', '`Gate edits: N listed, M read, K findings —', '`Gate edits: partial (', '`Gate edits: UNAVAILABLE —'], 'final report');
|
||||
expectMentions(report, [['unavailable', 'never', 'none detected']], 'final report');
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ function fixture(copy: boolean) {
|
||||
const shims = path.join(temp, 'shims');
|
||||
// Real setup, generator, resolver and migration code; only dependency
|
||||
// installation and binary compilation are stubbed to keep this test free.
|
||||
for (const dir of ['scripts', 'hosts', 'lib', 'model-overlays', 'browse/src', 'design/src', 'openclaw/templates', 'qa']) {
|
||||
for (const dir of ['scripts', 'hosts', 'lib', 'model-overlays', 'modules', 'browse/src', 'design/src', 'openclaw/templates', 'qa']) {
|
||||
fs.cpSync(path.join(ROOT, dir), path.join(root, dir), { recursive: true });
|
||||
}
|
||||
fs.copyFileSync(path.join(ROOT, 'setup'), path.join(root, 'setup'));
|
||||
|
||||
Reference in new issue
Block a user