@cratis/pi 7.103.4 → 7.103.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. package/package/corpus/harnesses/copilot/instructions/local-work-artifacts.instructions.md +7 -7
  2. package/package/corpus/harnesses/copilot/instructions/specs.csharp.instructions.md +7 -5
  3. package/package/corpus/hooks/README.md +16 -0
  4. package/package/corpus/hooks/scripts/cratis-cleanup-worktrees.sh +148 -24
  5. package/package/corpus/rules/local-work-artifacts.md +7 -7
  6. package/package/corpus/rules/specs.csharp.md +7 -5
  7. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/SKILL.md +12 -4
  8. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/dependency-map.md +76 -0
  9. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/dotnet-nuget.md +3 -0
  10. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/npm-yarn.md +6 -4
  11. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/train-mode.md +43 -0
  12. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/worked-examples.md +166 -28
  13. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/scripts/cratis-local-packages.sh +1065 -60
  14. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/scripts/dependency-map.tsv +68 -0
  15. package/package/corpus/skills/cratis-specifications-csharp/SKILL.md +20 -13
  16. package/package/corpus/skills/cratis-specifications-csharp/references/running-specs.md +90 -0
  17. package/package/corpus/skills/cratis-specifications-csharp/references/shared-lifecycle.md +55 -23
  18. package/package/corpus/skills/cratis-specifications-csharp/verification.json +5 -3
  19. package/package/corpus/skills/filter-syntax/SKILL.md +3 -1
  20. package/package/corpus/skills/run-tests/SKILL.md +1 -1
  21. package/package.json +1 -1
@@ -17,8 +17,9 @@ dumps, and similar coordination files. These are **work records, not documentati
17
17
  If you find one tracked in git, move it into `.ai-work/` and remove it from
18
18
  tracking in a dedicated commit.
19
19
  - A genuine follow-up that must survive the session is **not** a work record — suggest
20
- opening a GitHub issue for it (or open one when asked) so future work is tracked
21
- where everyone can see it, instead of leaving a planning file behind.
20
+ opening a GitHub issue for it so future work is tracked
21
+ where everyone can see it, instead of leaving a planning file behind. A follow-up
22
+ issue includes an `## Acceptance criteria` section of concrete, testable bullets.
22
23
  - Knowledge that must outlive the session (real documentation, ADRs, operator
23
24
  guides) is written deliberately into the repository's documentation structure
24
25
  through normal review — not left behind as a work record.
@@ -37,16 +38,15 @@ dumps, and similar coordination files. These are **work records, not documentati
37
38
  replace the prompt.
38
39
  - **A worktree is a work record too.** Create it outside the repository tree, for
39
40
  example in a sibling `../.worktrees/` directory. A nested worktree inherits the
40
- parent's `.globalconfig` alongside its own, causing .NET's `MultipleGlobalAnalyzerKeys`
41
+ parent's `.globalconfig`, causing .NET's `MultipleGlobalAnalyzerKeys`
41
42
  errors or silently changing analyzer severities. **Delivered work is cleaned up by
42
43
  `.cratis/ai/hooks/scripts/cratis-cleanup-worktrees.sh`, not by memory:** run it from the
43
44
  main checkout when a pull request merges or you finish a task (hooks also run it after
44
45
  `gh pr merge` and at session start where the harness supports them, and **ship-changes**
45
46
  ends with it). It removes a worktree and its branch only on evidence the work reached the
46
- default branch — pushed is not merged — and never forces. Worktrees with non-empty `.ai-work/`
47
- directories are kept by default as "has untracked work records (.ai-work/)". Manual
48
- `--include-work-records` permits deleting those records after you preserve what you need;
49
- automatic hooks never opt in. Pin a long-lived worktree with `git worktree lock <path>`;
47
+ default branch — pushed is not merged — and never forces. It keeps worktrees with non-empty
48
+ `.ai-work/` directories; manual `--include-work-records` deletes those records after you
49
+ preserve what you need, and automatic hooks never opt in. Pin a long-lived worktree with `git worktree lock <path>`;
50
50
  locked worktrees are always kept, even with opt-in. Other ignored files such as `.env` files
51
51
  and local databases can still be deleted with a removed worktree, so move anything you
52
52
  still need out first. Do not hand-remove worktrees or `git branch -D` a delivered branch
@@ -18,9 +18,9 @@ Extends the base [specs.md](./specs.instructions.md) (folder structure, naming,
18
18
 
19
19
  ## Frameworks
20
20
 
21
- - [xUnit](https://xunit.net/) **2.x** for execution (not v3).
22
- - Reference `Cratis.Specifications.XUnit` **4.1.1** for the `Specification` base, convention lifecycle and `ShouldXxx` assertions; the namespace remains `Cratis.Specifications`. Its `Cratis.Specifications` dependency supplies `Catch` and `SpecificationMethods`, not the base or assertions.
23
- - 4.x targets net8.0/net9.0/net10.0; net48 requires 3.x. `Cratis.Specifications.NUnit` is an alternative with `[Test]` and public test methods; xUnit remains the house default.
21
+ - [xUnit v3](https://xunit.net/docs/getting-started/v3/migration) is the house default for new and migrated spec projects: reference `Cratis.Specifications.XUnit.v3` **5.0** with `xunit.v3.mtp-off`, `Microsoft.NET.Test.Sdk` and `xunit.runner.visualstudio`, and set `<OutputType>Exe</OutputType>` in the project. The package supplies the `Specification` base, convention lifecycle and `ShouldXxx` assertions; the namespace remains `Cratis.Specifications`. Its `Cratis.Specifications` dependency supplies `Catch` and `SpecificationMethods`.
22
+ - 5.0 targets `net10.0` only. A spec project that must stay on net8.0/net9.0 stays on 4.x, and net48 on 3.x. A project that stays on xUnit v2 uses `Cratis.Specifications.XUnit`, with the same base and conventions.
23
+ - `.NUnit`/`.NUnit5`, `.TUnit` and the experimental `.Runner` are alternatives for repositories already built on them.
24
24
  - [NSubstitute](https://nsubstitute.github.io/) for mocking collaborators.
25
25
  - Spec projects are named `<Source>.Specs`.
26
26
 
@@ -48,9 +48,11 @@ public class when_combining_parts : Specification
48
48
  | `[Fact] void should_*()` | one assertion per fact | use the `ShouldXxx()` extension methods |
49
49
  | `void Destroy()` | teardown after each fact by default | once per eligible class with shared lifecycle opt-in |
50
50
 
51
- Shared lifecycle is optional (4.1.0+): add `[assembly: Xunit.TestFramework("Cratis.Specifications.SpecificationTestFramework", "Cratis.Specifications.XUnit")]`. Eligible classes derive from `Specification`, have one public parameterless constructor, do not implement `IDisposable`, and contain only plain facts. Construction, Establish/Because and Destroy run once; all facts share the instance and must not mutate it. Initialization failure fails every fact. Other classes keep per-fact execution. Keep the stock lifecycle by default; details are in the **cratis-specifications-csharp** skill's shared-lifecycle reference.
51
+ The shared lifecycle is the default on xUnit v2 and v3 since 5.0; the package registers the framework, so delete any hand-written `[assembly: Xunit.TestFramework(...)]` (it fails with `CS0579`). Eligible classes derive from `Specification`, have one public parameterless constructor, do not implement `IDisposable`, and contain only plain facts. Construction, Establish/Because and Destroy run once; all facts share the instance and must not mutate it. Initialization failure fails every fact. Theories and `ITestOutputHelper` constructors keep per-fact execution; `[PerFactSpecificationLifecycle]` opts a class out. `CRSPEC0008` (a fact modifies the shared context) is an error by default: move the change into `Establish` or `Because`. See the **cratis-specifications-csharp** skill's shared-lifecycle reference.
52
52
 
53
- CRSPEC0001–CRSPEC0007 are warnings bundled in `.Specifications`, `.XUnit`, and `.NUnit` 4.0.0+. With warnings as errors, a non-`should_` fact, multiple specs per file (given contexts exempt), a fact on a given context, a `should_` without a test attribute, explicit base lifecycle calls, non-public specs with facts, or `Because` on a given context fail the build. Follow **cratis-specification-by-example**; do not suppress these diagnostics.
53
+ Run specifications as the built executable, not `dotnet test`: restore and build once, then run `bin/<Config>/net10.0/<Project>.Specs`. In a solution that mixes Microsoft.Testing.Platform and VSTest projects, run each project on its own; `dotnet test` on the whole solution does not work there. Filters and the 4.x → 5.0 checklist are in the **cratis-specifications-csharp** skill's [running-specs reference](../../../skills/cratis-specifications-csharp/references/running-specs.md).
54
+
55
+ CRSPEC0001–CRSPEC0007 are warnings bundled in `.Specifications`, `.XUnit`, `.XUnit.v3`, and `.NUnit`. With warnings as errors, a non-`should_` fact, multiple specs per file (given contexts exempt), a fact on a given context, a `should_` without a test attribute, explicit base lifecycle calls, non-public specs with facts, or `Because` on a given context fail the build. Follow **cratis-specification-by-example**; do not suppress these diagnostics.
54
56
 
55
57
  ## Reusable layered contexts
56
58
 
@@ -128,6 +128,22 @@ ignored records. Other ignored files (`node_modules`, build output) go with a de
128
128
  `--include-work-records` only after preserving any records you still need: it permits deleting
129
129
  `.ai-work/` content, but never overrides locks or other safety checks. Hook mode ignores this flag;
130
130
  automatic hooks never opt in.
131
+ - **GitHub lookups.** The complete worktree and branch sweep is planned before any removal. Git
132
+ ancestry is checked first; only a plan needing more evidence triggers the single REST batch. It
133
+ reads the latest 100 closed pull requests, so older merged branches absent from that page are simply
134
+ kept. Only merged entries with the exact branch and head commit qualify. Hook runs share an atomic
135
+ stamp and cache under the Git common directory, with at most one request per repository every
136
+ 15 minutes, including failed attempts. A fresh successful cache is reused. A failed request, invalid
137
+ response, or unavailable lock without a fresh successful cache aborts **all** removals in that sweep,
138
+ including ancestry-proven worktrees and branches, with a one-line notice. Failed attempts are marked
139
+ for the full window: later plans needing GitHub also abort all removals without retrying, while plans
140
+ needing only ancestry evidence proceed without GitHub. Locks older than 15 minutes can be reclaimed.
141
+ Manual runs are not throttled, but also use at most one request per run and abort all removals if
142
+ that request fails.
143
+ - **Disable GitHub only.** Set `CRATIS_CLEANUP_NO_GH=1` to avoid all GitHub lookups while keeping
144
+ cleanup based on Git ancestry enabled. Missing `gh` or `jq`, a non-GitHub origin, or an invalid
145
+ repository slug also select ancestry-only cleanup before planning: unproven items are kept, with
146
+ no GitHub call, abort, or failed-attempt marker.
131
147
  - **Pin a long-lived worktree.** Use `git worktree lock <path>` to keep it out of cleanup; release the
132
148
  pin with `git worktree unlock <path>` when ready.
133
149
  - **Self-test.** `--self-test` plants delivered, dirty, unmerged, brand-new, work-record and locked cases
@@ -139,34 +139,128 @@ REMOVED=(); KEPT=(); FREED_KB=0
139
139
  note_removed() { REMOVED+=("$1"); }
140
140
  note_kept() { KEPT+=("$1"); }
141
141
 
142
- # delivered <sha> <branch|""> <upstream-track> → prints the evidence, returns 0 when delivered
143
- delivered() {
144
- local sha="$1" br="$2" track="$3"
145
- if git merge-base --is-ancestor "$sha" "$default" 2>/dev/null; then
146
- if ! grep -qx "$sha" "$firstparent"; then echo "merged into $default"; return 0; fi
147
- if [ "$track" = "[gone]" ]; then echo "merged into $default, upstream gone"; return 0; fi
142
+ # The cache is available only after a successful, valid batch. Failed attempts also stamp the window.
143
+ GH_PULLS=''
144
+ valid_pulls() {
145
+ jq -e -s 'length == 1 and (.[0] | type == "array" and all(.[];
146
+ type == "object" and has("merged_at") and (.merged_at == null or (.merged_at | type == "string")) and
147
+ (.head | type == "object" and (.ref | type == "string") and (.sha | type == "string"))))' "$1" >/dev/null 2>&1
148
+ }
149
+ lock_stat() { stat -c '%i %Y' "$1" 2>/dev/null || stat -f '%i %m' "$1" 2>/dev/null; }
150
+ fresh_stamp() {
151
+ local stamp
152
+ stamp=$(cat "$state/stamp" 2>/dev/null || true)
153
+ [[ "$stamp" =~ ^[0-9]+$ ]] && [ "$now" -lt "$((stamp + 900))" ]
154
+ }
155
+ cached_pulls() {
156
+ fresh_stamp && [ ! -e "$state/failed" ] && valid_pulls "$state/pulls.json" || return 1
157
+ cat "$state/pulls.json"
158
+ }
159
+ take_lock() {
160
+ mkdir "$state/lock" 2>/dev/null && return 0
161
+ local inode modified retired acquired current
162
+ read -r inode modified < <(lock_stat "$state/lock")
163
+ [[ "$modified" =~ ^[0-9]+$ ]] && [ "$now" -ge "$((modified + 900))" ] || return 1
164
+ # Rename atomically: a killed reclaimer can leave only a retired directory, never a blocking marker.
165
+ retired="$state/lock.stale.$$.$RANDOM"
166
+ mv "$state/lock" "$retired" 2>/dev/null || return 1
167
+ read -r current _ < <(lock_stat "$retired")
168
+ if [ "$current" != "$inode" ]; then
169
+ # Another reclaimer replaced the stale directory. Restore its lock if possible;
170
+ # never acquire or delete a directory that was not the stale one we inspected.
171
+ [ -e "$state/lock" ] || mv "$retired" "$state/lock" 2>/dev/null
172
+ return 1
148
173
  fi
149
- if [ -n "$br" ] && [ "${CRATIS_CLEANUP_NO_GH:-}" != 1 ] && command -v gh >/dev/null 2>&1; then
150
- if gh pr list --head "$br" --state merged --json headRefOid -q '.[].headRefOid' 2>/dev/null | grep -qx "$sha"; then
151
- echo "pull request merged at this commit"; return 0
152
- fi
174
+ acquired=1
175
+ mkdir "$state/lock" 2>/dev/null && acquired=0
176
+ rm -rf "$retired"
177
+ return "$acquired"
178
+ }
179
+ hook_pulls() (
180
+ local repo="$1" common state now temporary lock_inode current
181
+ common=$(git rev-parse --git-common-dir) || exit 1
182
+ state="$common/cratis-cleanup-gh/$repo"
183
+ now=$(date +%s)
184
+ mkdir -p "$state" 2>/dev/null || exit 1
185
+ if ! take_lock; then cached_pulls; exit $?; fi
186
+ read -r lock_inode _ < <(lock_stat "$state/lock")
187
+ temporary=''
188
+ # A retired owner must never remove its replacement's lock.
189
+ trap '[[ -z "$temporary" ]] || rm -f "$temporary"; read -r current _ < <(lock_stat "$state/lock"); [ "$current" != "$lock_inode" ] || rmdir "$state/lock" 2>/dev/null' EXIT
190
+ if fresh_stamp; then cached_pulls; exit $?; fi
191
+ temporary=$(mktemp "$state/pulls.XXXXXX") || exit 1
192
+ printf '%s\n' "$now" > "$temporary" && mv "$temporary" "$state/failed" || exit 1
193
+ printf '%s\n' "$now" > "$temporary" && mv "$temporary" "$state/stamp" || exit 1
194
+ read -r current _ < <(lock_stat "$state/lock")
195
+ [ "$current" = "$lock_inode" ] || exit 1
196
+ # Only the latest 100 closed PRs are searched; older merged branches are simply kept.
197
+ if gh api "repos/$repo/pulls?state=closed&per_page=100" > "$temporary" 2>/dev/null && valid_pulls "$temporary"; then
198
+ read -r current _ < <(lock_stat "$state/lock")
199
+ [ "$current" = "$lock_inode" ] || exit 1
200
+ mv "$temporary" "$state/pulls.json" && rm -f "$state/failed" || exit 1
201
+ cat "$state/pulls.json"
202
+ else exit 1; fi
203
+ )
204
+ GH_POSSIBLE=0; GH_REPO=''
205
+ configure_github() {
206
+ [ "${CRATIS_CLEANUP_NO_GH:-}" != 1 ] || return 0
207
+ command -v gh >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 || return 0
208
+ local remote repo
209
+ remote=$(git remote get-url origin 2>/dev/null) || return 0
210
+ case "$remote" in
211
+ https://github.com/*) repo=${remote#https://github.com/} ;;
212
+ git@github.com:*) repo=${remote#git@github.com:} ;;
213
+ ssh://git@github.com/*) repo=${remote#ssh://git@github.com/} ;;
214
+ *) return 0 ;;
215
+ esac
216
+ repo=${repo%.git}
217
+ [[ "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || return 0
218
+ GH_REPO=$repo; GH_POSSIBLE=1
219
+ }
220
+ configure_github
221
+ load_pulls() {
222
+ if [ "$HOOK" = 1 ]; then GH_PULLS=$(hook_pulls "$GH_REPO") || return 1
223
+ else GH_PULLS=$(gh api "repos/$GH_REPO/pulls?state=closed&per_page=100" 2>/dev/null) || return 1; fi
224
+ printf '%s' "$GH_PULLS" | valid_pulls /dev/stdin
225
+ }
226
+
227
+ # Ancestry evidence is resolved during planning; GitHub evidence only after the complete plan exists.
228
+ ancestry_delivered() {
229
+ local sha="$1" track="$3"
230
+ if git merge-base --is-ancestor "$sha" "$default" 2>/dev/null; then
231
+ if ! grep -qx "$sha" "$firstparent"; then EVIDENCE="merged into $default"; return 0; fi
232
+ if [ "$track" = "[gone]" ]; then EVIDENCE="merged into $default, upstream gone"; return 0; fi
153
233
  fi
154
234
  return 1
155
235
  }
236
+ github_delivered() {
237
+ printf '%s' "$GH_PULLS" | jq -e --arg br "$1" --arg sha "$2" \
238
+ 'any(.[]; .merged_at != null and .head.ref == $br and .head.sha == $sha)' >/dev/null 2>&1
239
+ }
240
+ PLAN_KIND=(); PLAN_PATH=(); PLAN_SHA=(); PLAN_BRANCH=(); PLAN_EVIDENCE=(); NEEDS_GH=0
241
+ plan_item() {
242
+ local kind="$1" path="$2" tip="$3" branch="$4" evidence=''
243
+ if ancestry_delivered "$tip" "$branch" "$([ -n "$branch" ] && track_of "$branch")"; then evidence=$EVIDENCE
244
+ elif [ -n "$branch" ] && [ "$GH_POSSIBLE" = 1 ]; then NEEDS_GH=1; fi
245
+ PLAN_KIND+=("$kind"); PLAN_PATH+=("$path"); PLAN_SHA+=("$tip")
246
+ PLAN_BRANCH+=("$branch"); PLAN_EVIDENCE+=("$evidence")
247
+ }
156
248
  track_of() { git for-each-ref --format='%(upstream:track)' "refs/heads/$1" 2>/dev/null; }
157
249
 
158
- # ── Worktrees ─────────────────────────────────────────────────────────────────
159
- git worktree prune >/dev/null 2>&1
250
+ # ── Plan the entire sweep without removing worktrees or branches ──────────────
160
251
  wt=""; sha=""; br=""; locked=0; first=1
161
252
  process_worktree() {
162
253
  [ -n "$wt" ] || return 0
163
254
  if [ "$first" = 1 ]; then first=0; return 0; fi # the main worktree is never removed
164
- local name="${br:-detached}" ev
255
+ local name="${br:-detached}"
165
256
  if [ "$locked" = 1 ]; then note_kept "worktree $wt ($name): locked"; return 0; fi
166
257
  case "$here/" in "$wt"/*) note_kept "worktree $wt ($name): it is the current directory - run the sweep from the main checkout"; return 0 ;; esac
167
- if ! ev=$(delivered "$sha" "$br" "$([ -n "$br" ] && track_of "$br")"); then
168
- note_kept "worktree $wt ($name): work not delivered to $default"; return 0
169
- fi
258
+ plan_item worktree "$wt" "$sha" "$br"
259
+ }
260
+ execute_worktree() {
261
+ local name="${br:-detached}" ev="$1" current
262
+ current=$(git -C "$wt" rev-parse HEAD 2>/dev/null) || return 0
263
+ [ "$current" = "$sha" ] || { note_kept "worktree $wt ($name): tip changed since planning"; return 0; }
170
264
  if [ "$INCLUDE_WORK_RECORDS" = 0 ] && [ -d "$wt/.ai-work" ]; then
171
265
  local records
172
266
  if ! records=$(find "$wt/.ai-work/." -mindepth 1 -maxdepth 1 -print -quit); then
@@ -191,20 +285,50 @@ while IFS= read -r line; do
191
285
  done < <(git worktree list --porcelain; echo)
192
286
  process_worktree
193
287
 
194
- # ── Local branches ────────────────────────────────────────────────────────────
195
288
  in_use=$(git worktree list --porcelain | sed -n 's#^branch refs/heads/##p')
289
+ planned_branches=$(printf '%s\n' "${PLAN_BRANCH[@]+"${PLAN_BRANCH[@]}"}")
196
290
  while IFS='|' read -r b bsha; do
197
291
  if [ -z "$b" ] || [ "$b" = "$default_local" ]; then continue; fi
198
- printf '%s\n' "$in_use" | grep -qx "$b" && continue
199
- if ev=$(delivered "$bsha" "$b" "$(track_of "$b")"); then
200
- if [ "$DRY" = 1 ]; then note_removed "branch $b: would delete, $ev"
201
- elif git branch -D "$b" >/dev/null 2>&1; then note_removed "branch $b: $ev"
202
- else note_kept "branch $b: git refused to delete it"; fi
203
- else
204
- note_kept "branch $b: work not delivered to $default"
292
+ # Drain producers under pipefail: grep -q can turn a match into a SIGPIPE failure.
293
+ if printf '%s\n' "$in_use" | grep -x "$b" >/dev/null; then
294
+ [ "$DRY" = 0 ] || continue
295
+ printf '%s\n' "$planned_branches" | grep -x "$b" >/dev/null || continue
205
296
  fi
297
+ plan_item branch "$b" "$bsha" "$b"
206
298
  done < <(git for-each-ref --format='%(refname:short)|%(objectname)' refs/heads)
207
299
 
300
+ abort_sweep() {
301
+ echo "cratis-cleanup-worktrees: GitHub evidence unavailable; 0 removed, keeping the entire sweep" >&2
302
+ exit 0
303
+ }
304
+ # Resolve every item's evidence before any deletion, including ancestry-proven items.
305
+ if [ "$NEEDS_GH" = 1 ] && ! load_pulls; then abort_sweep; fi
306
+ for ((i=0; i<${#PLAN_KIND[@]}; i++)); do
307
+ if [ -z "${PLAN_EVIDENCE[i]}" ] && [ -n "${PLAN_BRANCH[i]}" ] && [ -n "$GH_PULLS" ]; then
308
+ if github_delivered "${PLAN_BRANCH[i]}" "${PLAN_SHA[i]}"; then PLAN_EVIDENCE[i]="pull request merged at this commit"
309
+ else result=$?; [ "$result" = 1 ] || abort_sweep; fi
310
+ fi
311
+ done
312
+
313
+ # ── Execute the resolved plan, rechecking tips and worktree safety ─────────────
314
+ git worktree prune >/dev/null 2>&1
315
+ for ((i=0; i<${#PLAN_KIND[@]}; i++)); do
316
+ kind=${PLAN_KIND[i]}; path=${PLAN_PATH[i]}; sha=${PLAN_SHA[i]}; br=${PLAN_BRANCH[i]}; ev=${PLAN_EVIDENCE[i]}
317
+ if [ "$kind" = branch ]; then
318
+ git worktree list --porcelain | sed -n 's#^branch refs/heads/##p' | grep -x "$br" >/dev/null && continue
319
+ fi
320
+ if [ -z "$ev" ]; then
321
+ if [ "$kind" = worktree ]; then note_kept "worktree $path (${br:-detached}): work not delivered to $default"
322
+ else note_kept "branch $br: work not delivered to $default"; fi
323
+ continue
324
+ fi
325
+ if [ "$kind" = worktree ]; then wt=$path; execute_worktree "$ev"; continue; fi
326
+ [ "$(git rev-parse -q --verify "refs/heads/$br")" = "$sha" ] || { note_kept "branch $br: tip changed since planning"; continue; }
327
+ if [ "$DRY" = 1 ]; then note_removed "branch $br: would delete, $ev"
328
+ elif git branch -D "$br" >/dev/null 2>&1; then note_removed "branch $br: $ev"
329
+ else note_kept "branch $br: git refused to delete it"; fi
330
+ done
331
+
208
332
  # ── Report ────────────────────────────────────────────────────────────────────
209
333
  summary="cratis-cleanup-worktrees: ${#REMOVED[@]} removed, ${#KEPT[@]} kept"
210
334
  [ "$FREED_KB" -gt 0 ] && summary="$summary, $((FREED_KB / 1024)) MB freed"
@@ -16,8 +16,9 @@ dumps, and similar coordination files. These are **work records, not documentati
16
16
  If you find one tracked in git, move it into `.ai-work/` and remove it from
17
17
  tracking in a dedicated commit.
18
18
  - A genuine follow-up that must survive the session is **not** a work record — suggest
19
- opening a GitHub issue for it (or open one when asked) so future work is tracked
20
- where everyone can see it, instead of leaving a planning file behind.
19
+ opening a GitHub issue for it so future work is tracked
20
+ where everyone can see it, instead of leaving a planning file behind. A follow-up
21
+ issue includes an `## Acceptance criteria` section of concrete, testable bullets.
21
22
  - Knowledge that must outlive the session (real documentation, ADRs, operator
22
23
  guides) is written deliberately into the repository's documentation structure
23
24
  through normal review — not left behind as a work record.
@@ -36,16 +37,15 @@ dumps, and similar coordination files. These are **work records, not documentati
36
37
  replace the prompt.
37
38
  - **A worktree is a work record too.** Create it outside the repository tree, for
38
39
  example in a sibling `../.worktrees/` directory. A nested worktree inherits the
39
- parent's `.globalconfig` alongside its own, causing .NET's `MultipleGlobalAnalyzerKeys`
40
+ parent's `.globalconfig`, causing .NET's `MultipleGlobalAnalyzerKeys`
40
41
  errors or silently changing analyzer severities. **Delivered work is cleaned up by
41
42
  `.cratis/ai/hooks/scripts/cratis-cleanup-worktrees.sh`, not by memory:** run it from the
42
43
  main checkout when a pull request merges or you finish a task (hooks also run it after
43
44
  `gh pr merge` and at session start where the harness supports them, and **ship-changes**
44
45
  ends with it). It removes a worktree and its branch only on evidence the work reached the
45
- default branch — pushed is not merged — and never forces. Worktrees with non-empty `.ai-work/`
46
- directories are kept by default as "has untracked work records (.ai-work/)". Manual
47
- `--include-work-records` permits deleting those records after you preserve what you need;
48
- automatic hooks never opt in. Pin a long-lived worktree with `git worktree lock <path>`;
46
+ default branch — pushed is not merged — and never forces. It keeps worktrees with non-empty
47
+ `.ai-work/` directories; manual `--include-work-records` deletes those records after you
48
+ preserve what you need, and automatic hooks never opt in. Pin a long-lived worktree with `git worktree lock <path>`;
49
49
  locked worktrees are always kept, even with opt-in. Other ignored files such as `.env` files
50
50
  and local databases can still be deleted with a removed worktree, so move anything you
51
51
  still need out first. Do not hand-remove worktrees or `git branch -D` a delivered branch
@@ -17,9 +17,9 @@ Extends the base [specs.md](./specs.md) (folder structure, naming, philosophy) w
17
17
 
18
18
  ## Frameworks
19
19
 
20
- - [xUnit](https://xunit.net/) **2.x** for execution (not v3).
21
- - Reference `Cratis.Specifications.XUnit` **4.1.1** for the `Specification` base, convention lifecycle and `ShouldXxx` assertions; the namespace remains `Cratis.Specifications`. Its `Cratis.Specifications` dependency supplies `Catch` and `SpecificationMethods`, not the base or assertions.
22
- - 4.x targets net8.0/net9.0/net10.0; net48 requires 3.x. `Cratis.Specifications.NUnit` is an alternative with `[Test]` and public test methods; xUnit remains the house default.
20
+ - [xUnit v3](https://xunit.net/docs/getting-started/v3/migration) is the house default for new and migrated spec projects: reference `Cratis.Specifications.XUnit.v3` **5.0** with `xunit.v3.mtp-off`, `Microsoft.NET.Test.Sdk` and `xunit.runner.visualstudio`, and set `<OutputType>Exe</OutputType>` in the project. The package supplies the `Specification` base, convention lifecycle and `ShouldXxx` assertions; the namespace remains `Cratis.Specifications`. Its `Cratis.Specifications` dependency supplies `Catch` and `SpecificationMethods`.
21
+ - 5.0 targets `net10.0` only. A spec project that must stay on net8.0/net9.0 stays on 4.x, and net48 on 3.x. A project that stays on xUnit v2 uses `Cratis.Specifications.XUnit`, with the same base and conventions.
22
+ - `.NUnit`/`.NUnit5`, `.TUnit` and the experimental `.Runner` are alternatives for repositories already built on them.
23
23
  - [NSubstitute](https://nsubstitute.github.io/) for mocking collaborators.
24
24
  - Spec projects are named `<Source>.Specs`.
25
25
 
@@ -47,9 +47,11 @@ public class when_combining_parts : Specification
47
47
  | `[Fact] void should_*()` | one assertion per fact | use the `ShouldXxx()` extension methods |
48
48
  | `void Destroy()` | teardown after each fact by default | once per eligible class with shared lifecycle opt-in |
49
49
 
50
- Shared lifecycle is optional (4.1.0+): add `[assembly: Xunit.TestFramework("Cratis.Specifications.SpecificationTestFramework", "Cratis.Specifications.XUnit")]`. Eligible classes derive from `Specification`, have one public parameterless constructor, do not implement `IDisposable`, and contain only plain facts. Construction, Establish/Because and Destroy run once; all facts share the instance and must not mutate it. Initialization failure fails every fact. Other classes keep per-fact execution. Keep the stock lifecycle by default; details are in the **cratis-specifications-csharp** skill's shared-lifecycle reference.
50
+ The shared lifecycle is the default on xUnit v2 and v3 since 5.0; the package registers the framework, so delete any hand-written `[assembly: Xunit.TestFramework(...)]` (it fails with `CS0579`). Eligible classes derive from `Specification`, have one public parameterless constructor, do not implement `IDisposable`, and contain only plain facts. Construction, Establish/Because and Destroy run once; all facts share the instance and must not mutate it. Initialization failure fails every fact. Theories and `ITestOutputHelper` constructors keep per-fact execution; `[PerFactSpecificationLifecycle]` opts a class out. `CRSPEC0008` (a fact modifies the shared context) is an error by default: move the change into `Establish` or `Because`. See the **cratis-specifications-csharp** skill's shared-lifecycle reference.
51
51
 
52
- CRSPEC0001–CRSPEC0007 are warnings bundled in `.Specifications`, `.XUnit`, and `.NUnit` 4.0.0+. With warnings as errors, a non-`should_` fact, multiple specs per file (given contexts exempt), a fact on a given context, a `should_` without a test attribute, explicit base lifecycle calls, non-public specs with facts, or `Because` on a given context fail the build. Follow **cratis-specification-by-example**; do not suppress these diagnostics.
52
+ Run specifications as the built executable, not `dotnet test`: restore and build once, then run `bin/<Config>/net10.0/<Project>.Specs`. In a solution that mixes Microsoft.Testing.Platform and VSTest projects, run each project on its own; `dotnet test` on the whole solution does not work there. Filters and the 4.x → 5.0 checklist are in the **cratis-specifications-csharp** skill's [running-specs reference](../skills/cratis-specifications-csharp/references/running-specs.md).
53
+
54
+ CRSPEC0001–CRSPEC0007 are warnings bundled in `.Specifications`, `.XUnit`, `.XUnit.v3`, and `.NUnit`. With warnings as errors, a non-`should_` fact, multiple specs per file (given contexts exempt), a fact on a given context, a `should_` without a test attribute, explicit base lifecycle calls, non-public specs with facts, or `Because` on a given context fail the build. Follow **cratis-specification-by-example**; do not suppress these diagnostics.
53
55
 
54
56
  ## Reusable layered contexts
55
57
 
@@ -55,12 +55,14 @@ commands before choosing the build, regression spec and full gate commands.
55
55
  ## Copy this checklist
56
56
 
57
57
  - [ ] Reproduce the consumer bug and select its regression spec/app action.
58
- - [ ] Generate one stamp; build and pack the producer's full dependency closure.
58
+ - [ ] Read [references/dependency-map.md](references/dependency-map.md) for the chain between the named repositories, generate one stamp, then pack each producer in upstream order.
59
59
  - [ ] If needed, publish the kernel and assets, then build a matching local image.
60
+ - [ ] Run `closure` for the consumer and producers, follow the printed commands, and run its one `verify-pack` after the last pack.
60
61
  - [ ] Activate the consumer overlay and select the local image.
61
62
  - [ ] Build the consumer, run its regression specs/app action and required gates.
62
63
  - [ ] On failure, diagnose, fix the producer, create a distinct stamp and repeat.
63
- - [ ] Run `check` before every consumer commit and push, and on the producer.
64
+ - [ ] Run `check` before every consumer commit and push, and on the producer; it must report 0 leaks.
65
+ - [ ] Upstream first: a downstream ships only after the upstream has released and its pin is bumped to the released version.
64
66
  - [ ] Restore overlays, unset environment overrides, and run `check` again.
65
67
  - [ ] Review stamp-scoped `clean` dry-run targets, then apply cleanup.
66
68
 
@@ -92,7 +94,12 @@ For npm, complete the repository build before
92
94
  exit `0`. Inspect package dependencies: every locally changed dependency ID must
93
95
  resolve to the stamp, not a published fallback. Chronicle's client solution filter
94
96
  can omit `Cratis.Chronicle.CodeAnalysis`; pack its code-analysis package project
95
- separately. A successful pack alone does not prove dependency closure.
97
+ separately. The package IDs a producer should ship are listed in
98
+ [dependency-map.tsv](scripts/dependency-map.tsv); `closure CONSUMER=PATH PRODUCER=PATH...` prints
99
+ the stamp, every overlay, one `pack-*` command per map row (with its `--property` arguments) and
100
+ ONE `verify-pack` for the whole plan (see [references/dependency-map.md](references/dependency-map.md)).
101
+ Run the printed commands in order. A successful pack alone does not prove dependency closure;
102
+ `verify-pack` does.
96
103
 
97
104
  ### 3. Build an image when needed
98
105
 
@@ -237,7 +244,8 @@ minutes, instead of after a release and a pin bump.
237
244
  - Read [references/npm-yarn.md](references/npm-yarn.md) when packing frontend libraries or restoring Yarn overlays.
238
245
  - Read [references/docker-images.md](references/docker-images.md) when publishing a kernel or selecting images in a consumer.
239
246
  - Read [references/go-jvm-python-elixir.md](references/go-jvm-python-elixir.md) when the boundary uses another language toolchain.
240
- - Read [references/worked-examples.md](references/worked-examples.md) when following Chronicle → Studio, Fundamentals → Arc or Components → Studio.
247
+ - Read [references/worked-examples.md](references/worked-examples.md) when following Chronicle → Studio, Fundamentals → Arc, Components → Studio, Chronicle → Direct, Screenplay → Stage and Studio, Arc → Chronicle, or Fundamentals → Arc → Chronicle.
248
+ - Read [references/train-mode.md](references/train-mode.md) when verifying a downstream repository against a local release train (`stamp --train`).
241
249
  - Read [references/other-environments.md](references/other-environments.md) when running on another developer machine or an agent host.
242
250
 
243
251
  Read `scripts/npm-restore-check.cjs` when inspecting the read-only npm restoration
@@ -0,0 +1,76 @@
1
+ <!-- Copyright (c) Cratis. All rights reserved. -->
2
+ <!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
3
+
4
+ # Dependency map
5
+
6
+ `scripts/dependency-map.tsv` is a reference table of the producer-to-consumer edges between
7
+ Cratis repositories, the pack targets of each producer and the package IDs each ships.
8
+ `cratis-local-packages.sh` reads it (or the file in `CRATIS_LOCAL_MAP`, or `--map FILE`) to derive
9
+ one plan: which repositories to pack, in which order, and which IDs the feed must hold. It holds
10
+ no versions; read pins live from the consumer manifests (`Directory.Packages.props`,
11
+ `package.json`, `Dockerfile`).
12
+
13
+ ## Rows
14
+
15
+ Tab-separated, `#` starts a comment.
16
+
17
+ | Row | Fields | Meaning |
18
+ | --- | --- | --- |
19
+ | `pack` | repo, eco, target, `PROP=VAL,...` | A pack target; the optional MSBuild properties are the `-p:` arguments the producer's own publish workflow passes. `closure` prints each as one `--property NAME=VALUE` argument of that row's `pack-nuget` command |
20
+ | `ids` | repo, eco, comma-separated IDs | The packages the producer ships in that ecosystem |
21
+ | `uses` | consumer, producer, eco, manifest | An edge; the manifest is the pin file, or the Yarn project directory for npm |
22
+ | `image` | repo, dockerfile, tag suffix | A producer image a consumer builds on |
23
+
24
+ `eco` is `nuget`, `npm` or `image`. For npm, `.` means the repository root.
25
+
26
+ ## Using it
27
+
28
+ - Find every `uses` row whose producer is the repository you changed; those consumers
29
+ pin its packages. A consumer may also reach the producer through another repository
30
+ (Fundamentals reaches Chronicle through Arc as well as directly).
31
+ - Pack upstream first. Overlay the earlier producer into the next repository with
32
+ `use-nuget`, pack that, then `restore` it. Use one stamp for the whole chain: `stamp`
33
+ takes several producers, so generate it once and reuse the string.
34
+ - Read the closure: `closure [--train] CONSUMER=PATH PRODUCER=PATH...` (producers in dependency
35
+ order) prints the commands to run, in order, from the plan; `plan CONSUMER PRODUCER...` prints
36
+ the plan rows alone. Both exit 2 and print nothing when the plan is invalid.
37
+ - Pack properties are `pack-nuget --property NAME=VALUE` arguments, one per `-p:` of that command
38
+ only; never export them (an exported `IsPackaging=true` reaches Chronicle's nested `dotnet run`
39
+ code generator and fails with a multi-target error).
40
+ - `--train` prints the train stamp (see [train-mode.md](train-mode.md)) instead of a local stamp.
41
+
42
+ ## Pack plan and verification scope
43
+
44
+ The plan is a set of `(producer, ecosystem)` pack rows, overlay rows (a producer and the upstream
45
+ producers it consumes) and consume rows (the consumer's own edges), derived from the `uses` rows
46
+ for the names you list. It is rejected (exit 2) for an unknown or repeated name, no edge, a wrong
47
+ order, or a planned pack without `ids` or `pack` rows.
48
+
49
+ - Run `verify-pack STAMP --consumer C --producer P...` once, after the last pack. It checks the
50
+ planned IDs exist at exactly STAMP and each planned NuGet dependency is exactly STAMP, [STAMP] or [STAMP, ) (an unreadable dependency element fails).
51
+ - After `use-*` and restore, `verify-assets CONSUMER-PATH STAMP --consumer C --producer P...`
52
+ checks the same plan inside the consumer: no planned ID resolved at another version, and no
53
+ producer the consumer uses left unconsumed. `--json` lists the resolved IDs only.
54
+ - Each command derives and checks the plan itself and keeps no state between runs.
55
+ - `verify-pack STAMP NAME...` and `verify-assets CONSUMER STAMP` without a plan are manual and
56
+ weaker: they check the named repositories' IDs, or every stamped package the consumer resolves.
57
+
58
+ ## Facts about the chains
59
+
60
+ - Arc and Chronicle depend on each other. Arc's Chronicle-facing packages are built against
61
+ a released Chronicle, and Chronicle's clients against a released Arc. The same holds for
62
+ Chronicle and Orleans. Record the nuspec dependencies and do not break the cycle.
63
+ - Image and package pins are coupled. A consumer that builds `FROM` a producer image may
64
+ derive the tag from the producer's NuGet pin; check its publish workflow before tagging a
65
+ local image.
66
+ - A repository you did not name keeps its released version: Stage reaches Arc, Chronicle
67
+ reaches Orleans. A binary break through that hop is a finding about the consumer's pins.
68
+ - The current Screenplay rows list only npm IDs although Stage and Studio consume its NuGet
69
+ packages; `closure` exits 2 for them until the map has the Screenplay NuGet `ids`. Read the
70
+ IDs from the producer's publish workflow and add them first.
71
+
72
+ ## Extend it
73
+
74
+ Add `pack` rows only from the producer's publish workflow on the default branch, not a
75
+ stale clone. Add `ids` for every package a consumer must resolve. Add one `uses` row per
76
+ consumer manifest that pins the producer.
@@ -28,6 +28,9 @@ dotnet restore path/to/consumer.slnx
28
28
  dotnet build path/to/consumer.slnx -c Release --no-restore -warnaserror
29
29
  ```
30
30
 
31
+ Transitive pinning stays as the consumer sets it; `--pin-transitive` forces it on so stamped IDs the
32
+ consumer does not pin still take effect (it can raise NU1109 floors).
33
+
31
34
  The printed properties are `DirectoryPackagesPropsPath` and
32
35
  `RestoreAdditionalProjectSources`. For an IDE, launch it from that shell, or pass
33
36
  the printed `-p:` properties explicitly to its build. Verify restore uses the
@@ -5,8 +5,9 @@
5
5
 
6
6
  ## Pack the package, not a link
7
7
 
8
- Requires node, tar and Yarn 4. Run the producer's normal build first. The helper does
9
- not run a separate build; Yarn's normal pack lifecycle hooks still run.
8
+ Requires node, npm and Yarn 4. Run the producer's normal build first. The helper does
9
+ not build or run lifecycle scripts: it packs with `npm pack --ignore-scripts`, the publish
10
+ workflows' tool, so the tarball matches the published one.
10
11
 
11
12
  ```bash
12
13
  stamp=$(bash "$local" stamp "$producer")
@@ -15,8 +16,9 @@ bash "$local" pack-npm "$producer" "$stamp"
15
16
 
16
17
  It requires clean tracked workspace manifests, temporarily changes all non-private
17
18
  workspace versions and packs actual tarballs. It restores those manifests on
18
- success and failure; inspect diffs for other lifecycle-generated output. Yarn's
19
- `%s-%v.tgz` output slugifies a scoped name (`@cratis/foo` becomes `@cratis-foo`).
19
+ success and failure. Tarballs are named `<scope>-<name>-<version>.tgz` (`@cratis/foo` becomes
20
+ `cratis-foo-<version>.tgz`). `verify-assets --npm [--dir REL]` reads installed copies under
21
+ `node_modules`, or under `REL` when the consumer's Yarn project is a subfolder.
20
22
  Inspect dependency ranges in packed manifests: the helper changes versions, not
21
23
  arbitrary inter-package dependency constraints. Include every required workspace
22
24
  and verify the consumer resolves the intended tarballs.
@@ -0,0 +1,43 @@
1
+ <!-- Copyright (c) Cratis. All rights reserved. -->
2
+ <!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
3
+
4
+ # Train mode
5
+
6
+ A local release train combines several branches into one pull request, one CI run and one
7
+ release. Verify a downstream repository against the upstream train's packages, not against
8
+ per-branch packages.
9
+
10
+ ## Stamp
11
+
12
+ `stamp --train PRODUCER...` prints `X.Y.Z-train.g<sha7>`. X.Y.Z is the highest next patch over the
13
+ producers' latest reachable tags, so no stamped package sorts below a released pin. sha7 is the first
14
+ seven hex characters of the SHA-1 of the sorted `<repository> <full HEAD sha>` lines of every
15
+ producer (the repository is its origin's last path part, else its folder name). There is no minute: one
16
+ combined code state yields one stamp, a change in any producer changes it, and the order of the
17
+ producers does not matter. The grammar is fixed so tools can rely on it. Every command that takes a
18
+ stamp accepts it: `pack-*`, `use-*`, `verify-*`, `check` and `clean --stamp`. A train stamp
19
+ sorts above the released pin like a local stamp does.
20
+
21
+ ## Rules
22
+
23
+ - One stamp per train state. The train tool records it in the train manifest; read it from there
24
+ instead of recomputing it for a different HEAD.
25
+ - A fold into the upstream train changes the upstream HEAD and so the stamp. Downstream
26
+ verification against the old stamp is stale: pack again, overlay the new stamp, rebuild and
27
+ re-verify. Do not carry a pass over a fold.
28
+ - Train versions are as local as local stamps. `check` reports `-train.g<sha7>` in changed
29
+ tracked, staged and untracked files, so a train version never reaches a commit or pull request.
30
+ - Upstream first. The downstream ships only after the upstream has released and the downstream pin
31
+ is bumped to the released version; run `check` (0 leaks) before the downstream push.
32
+
33
+ ## Recording consumed versions
34
+
35
+ `verify-pack STAMP --consumer C --producer P... --json` and
36
+ `verify-assets CONSUMER STAMP [--npm] [--consumer C --producer P...] --json` print one line:
37
+ `{"stamp":"...","packages":[{"id":"...","version":"..."}]}`. Findings go to stderr and the exit status
38
+ is unchanged, so a train tool can record the consumed versions without scraping text.
39
+
40
+ ## Clean
41
+
42
+ `clean --stamp STAMP` lists and removes the feed files, cache folders and Yarn cache entries of
43
+ that one train stamp; a local stamp is not matched. Review the dry run before `--apply`.