nobrainer-tech-flow 2.4.1 → 2.5.0

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.
@@ -19,7 +19,7 @@ unset CDPATH
19
19
  # The eighteen skills, and the names earlier layouts used. Tests keep these lists
20
20
  # equal to the ones in scripts/install_skills.py.
21
21
  SKILLS='nobrainer-auto-fine-tune nobrainer-autoimprove nobrainer-browser nobrainer-build
22
- nobrainer-codex-context nobrainer-decide nobrainer-dispatcher nobrainer-rca
22
+ nobrainer-context nobrainer-decide nobrainer-dispatcher nobrainer-rca
23
23
  nobrainer-research nobrainer-review nobrainer-security nobrainer-sessions
24
24
  nobrainer-skill-doctor nobrainer-spec-driven-development nobrainer-team
25
25
  nobrainer-tech-flow nobrainer-wiki nobrainer-writing'
@@ -28,13 +28,13 @@ deep-audit deep-autoresearch deep-autoreview deep-bugs-finder deep-code-review
28
28
  deep-decide deep-rca dispatching-parallel-agents engineering-standards
29
29
  karpathy-auto-improver karpathy-llm-wiki llm-wiki nb-add nb-dispatcher nb-flow nb-get
30
30
  nb-multi nb-tidy nb-workflow nb-write nobrainer-autopilot nobrainer-capture-lesson
31
- nobrainer-continuous-improvement nobrainer-fast-audit nobrainer-human-like
31
+ nobrainer-codex-context nobrainer-continuous-improvement nobrainer-fast-audit nobrainer-human-like
32
32
  nobrainer-memory nobrainer-memory-memsearch nobrainer-npm-secure nobrainer-reddit
33
33
  nobrainer-simplifier nobrainer-skill-browser nobrainer-starter nobrainer-style
34
34
  nobrainer-team-builder nobrainer-ultra nobrainer-ultracode-workflow nobrainer-wiki-add
35
35
  nobrainer-wiki-get nobrainer-wiki-tidy playwright-cli security-review session-handoff
36
36
  wiki-add wiki-get wiki-tidy'
37
- RETIRED='nobrainer-reddit'
37
+ RETIRED='nobrainer-codex-context nobrainer-reddit'
38
38
  START='<!-- NOBRAINER-TECH-FLOW:START -->'
39
39
  END='<!-- NOBRAINER-TECH-FLOW:END -->'
40
40
  LEGACY_TEXT='(^|[^[:alnum:]_])nobrainer-ultra([^[:alnum:]_]|$)|^[[:space:]]*#{1,6}[[:space:]]+nobrainer([ .]?tech)?[[:space:]]+flow([^[:alnum:]_]|$)'
@@ -285,6 +285,38 @@ is_our_link() {
285
285
  [ "$_o_resolved" = "$2" ]
286
286
  }
287
287
 
288
+ # Put a claimed entry ($1) back at its name ($2) unless something new stands there; the
289
+ # private folder it was moved into goes too. A blocked restore keeps the claim and says where.
290
+ unclaim() {
291
+ # Restore through the same native no-replace primitive as the Python installer.
292
+ # Normal shell installs need no Python; without it, a recovery claim stays intact.
293
+ if command -v python3 >/dev/null 2>&1 && python3 - "$root/scripts" "$1" "$2" <<'PYTHON'
294
+ import sys
295
+ from pathlib import Path
296
+ sys.path.insert(0, sys.argv[1])
297
+ from install_skills import restore_claim
298
+ try:
299
+ restore_claim(Path(sys.argv[3]), Path(sys.argv[2]))
300
+ except (OSError, RuntimeError) as exc:
301
+ print(exc, file=sys.stderr)
302
+ sys.exit(1)
303
+ PYTHON
304
+ then
305
+ return 0
306
+ fi
307
+ err "PRESERVED: atomic restore unavailable or blocked; could not put $2 back; it is kept at $1"
308
+ return 1
309
+ }
310
+
311
+ # A recorded skill link this checkout made: $1 is the link, $2 the skill name. The link of
312
+ # a retired module dangles, so it is compared as written.
313
+ owns_link() {
314
+ case " $RETIRED " in
315
+ *" $2 "*) is_exact_legacy_link "$1" "$2" ;;
316
+ *) is_our_link "$1" "$root/skills/$2" ;;
317
+ esac
318
+ }
319
+
288
320
  # Resolve links before "..", including dangling final links. A trailing slash
289
321
  # preserves a pathname's trailing newlines across command substitution.
290
322
  resolve_legacy_path() (
@@ -410,7 +442,8 @@ read_state() {
410
442
  ' ]' | ' ],') _r_in= ;;
411
443
  *)
412
444
  json_field "\": ${_r_line# }" || return 1
413
- case " $(printf '%s' "$SKILLS" | tr '\n' ' ') " in *" $json_value "*) ;; *) return 1 ;; esac
445
+ # A record from an earlier release may hold a module since retired.
446
+ case " $(printf '%s' "$SKILLS $RETIRED" | tr '\n' ' ') " in *" $json_value "*) ;; *) return 1 ;; esac
414
447
  case " $st_created " in *" $json_value "*) return 1 ;; esac
415
448
  st_created="$st_created $json_value" ;;
416
449
  esac
@@ -681,7 +714,7 @@ check_record() {
681
714
  }
682
715
 
683
716
  plan_skills() {
684
- to_link= link_count=0 kept_count=0 problems=
717
+ to_link= link_count=0 kept_count=0 problems= to_retire=
685
718
  for _p_name in $SKILLS; do
686
719
  _p_source=$root/skills/$_p_name
687
720
  [ -f "$_p_source/SKILL.md" ] || die 2 "this checkout is incomplete: $_p_source/SKILL.md is missing"
@@ -708,7 +741,13 @@ CONFLICT: $_p_name: $_p_source -> $_p_target"
708
741
  if ! is_exact_legacy_link "$_p_target" "$_p_name"; then
709
742
  err "PRESERVED_FOREIGN: $_p_name: retired from nobrainer-tech-flow; left unchanged: $_p_target"
710
743
  continue
711
- fi ;;
744
+ fi
745
+ # This setup's record lists the link, as an undo would remove it.
746
+ case " $st_created " in
747
+ *" $_p_name "*)
748
+ to_retire="$to_retire $_p_name"
749
+ continue ;;
750
+ esac ;;
712
751
  esac
713
752
  problems="$problems
714
753
  LEGACY: $_p_name: $_p_target"
@@ -819,6 +858,9 @@ variable_note() {
819
858
  preview() {
820
859
  say "$source_line for $label"
821
860
  say "SKILLS: $link_count to link into $skills_dir ($kept_count already linked)"
861
+ for _v_name in $to_retire; do
862
+ say "REMOVE_RETIRED: $_v_name: made by this setup, now renamed: $skills_dir/$_v_name"
863
+ done
822
864
  if [ "$inherit" = 1 ]; then
823
865
  say "INHERITS_CODEX: $target imports $codex_file; no duplicate block added"
824
866
  else
@@ -914,6 +956,10 @@ write_instructions() {
914
956
  }
915
957
 
916
958
  install() {
959
+ # plan_skills needs the retired links the record lists; check_record judges the record
960
+ # after the skills, as before, so a record that cannot be read only lists none here.
961
+ st_created=
962
+ if [ -f "$state" ] && [ ! -L "$state" ]; then read_state "$state" || st_created=; fi
917
963
  plan_skills
918
964
  plan_instructions
919
965
  check_record
@@ -986,8 +1032,43 @@ install() {
986
1032
  elif [ "$wrote" = 1 ]; then
987
1033
  new_target=$target new_backup=$made_backup new_hash=$(sha256 "$target")
988
1034
  fi
989
- if [ -n "$created" ] || [ "$wrote" = 1 ] || { [ "$record" = 1 ] && [ "$new_hash" != "$st_hash" ]; }; then
990
- new_created=$(printf '%s\n' $st_created $created | LC_ALL=C sort -u | tr '\n' ' ')
1035
+ # The retired links the record lists go last, after every other write. Each is moved into
1036
+ # a private folder and checked there; one that changed after the check goes back as it is.
1037
+ # They come back if the record cannot be saved, and are deleted only once it is.
1038
+ retired_claims= retirement_error= retirement_restore_error=
1039
+ for _a_name in $to_retire; do
1040
+ _a_link=$skills_dir/$_a_name
1041
+ is_exact_legacy_link "$_a_link" "$_a_name" || continue
1042
+ _a_text=$(readlink "$_a_link") || continue
1043
+ if ! _a_claim=$(mktemp -d "$skills_dir/.nobrainer-retire-XXXXXX"); then
1044
+ retirement_error=1
1045
+ break
1046
+ fi
1047
+ if ! mv "$_a_link" "$_a_claim/$_a_name" 2>/dev/null; then
1048
+ rmdir "$_a_claim"
1049
+ continue
1050
+ fi
1051
+ if [ -L "$_a_claim/$_a_name" ] && [ "$(readlink "$_a_claim/$_a_name")" = "$_a_text" ]; then
1052
+ retired_claims="$retired_claims$_a_claim/$_a_name
1053
+ "
1054
+ else
1055
+ if ! unclaim "$_a_claim/$_a_name" "$_a_link"; then
1056
+ retirement_error=1 retirement_restore_error=1
1057
+ break
1058
+ fi
1059
+ err "PRESERVED: $_a_link changed during the setup and is left as it is"
1060
+ fi
1061
+ done
1062
+ if [ -n "$created" ] || [ -n "$to_retire" ] || [ "$wrote" = 1 ] || { [ "$record" = 1 ] && [ "$new_hash" != "$st_hash" ]; }; then
1063
+ # A retired module's link is gone once migrated; then the record drops its name.
1064
+ _k_created=
1065
+ for _k_name in $st_created; do
1066
+ case " $RETIRED " in
1067
+ *" $_k_name "*) owns_link "$skills_dir/$_k_name" "$_k_name" || continue ;;
1068
+ esac
1069
+ _k_created="$_k_created $_k_name"
1070
+ done
1071
+ new_created=$(printf '%s\n' $_k_created $created | LC_ALL=C sort -u | tr '\n' ' ')
991
1072
  new_created=${new_created# }
992
1073
  new_created=${new_created% }
993
1074
  # Of the folders this run meant to create, the ones that exist now, with the ones an
@@ -1011,7 +1092,7 @@ install() {
1011
1092
  # makes a folder, and its undo keeps the rule it was made under.
1012
1093
  want_dirs=1
1013
1094
  if [ "$record" = 1 ] && [ -z "$st_has_dirs" ] && [ -z "$new_dirs" ]; then want_dirs=; fi
1014
- if ! write_state; then
1095
+ if [ -n "$retirement_error" ] || ! write_state; then
1015
1096
  for _a_done in $created; do remove_link "$skills_dir/$_a_done"; done
1016
1097
  if [ "$wrote" = 1 ]; then
1017
1098
  # Through a copy beside it and a rename, so a read-only file comes back as well.
@@ -1022,9 +1103,21 @@ install() {
1022
1103
  rm -f "$target"
1023
1104
  fi
1024
1105
  fi
1106
+ if ! printf '%s' "$retired_claims" | while IFS= read -r _a_claim; do
1107
+ unclaim "$_a_claim" "$skills_dir/${_a_claim##*/}" || exit 1
1108
+ done; then
1109
+ die 4 "could not save the setup record $state; rollback incomplete; recovery claims were preserved"
1110
+ fi
1111
+ if [ -n "$retirement_restore_error" ]; then
1112
+ die 4 "retired link restore failed; rollback incomplete; recovery claims were preserved"
1113
+ fi
1025
1114
  die 4 "could not save the setup record $state; the changes of this run were reversed"
1026
1115
  fi
1027
1116
  fi
1117
+ printf '%s' "$retired_claims" | while IFS= read -r _a_claim; do
1118
+ remove_link "$_a_claim" && rmdir "${_a_claim%/*}" 2>/dev/null
1119
+ say "REMOVED_RETIRED: $skills_dir/${_a_claim##*/}"
1120
+ done
1028
1121
 
1029
1122
  # A home made for a record that was not needed goes again.
1030
1123
  if [ ! -e "$state" ] && [ -n "$made_home" ]; then
@@ -1033,7 +1126,7 @@ install() {
1033
1126
 
1034
1127
  # New links and a written instruction file are what a client has to be restarted for.
1035
1128
  changed=0
1036
- if [ "$link_count" -gt 0 ] || [ "$wrote" = 1 ]; then changed=1; fi
1129
+ if [ "$link_count" -gt 0 ] || [ "$wrote" = 1 ] || [ -n "$retired_claims" ]; then changed=1; fi
1037
1130
  if [ "$changed" = 1 ]; then
1038
1131
  say "INSTALLED: $source_line for $label"
1039
1132
  else
@@ -1215,7 +1308,7 @@ undo() {
1215
1308
  for _u_name in $st_created; do
1216
1309
  _u_link=$st_dest/$_u_name
1217
1310
  if [ -L "$_u_link" ]; then
1218
- if ! is_our_link "$_u_link" "$root/skills/$_u_name"; then
1311
+ if ! owns_link "$_u_link" "$_u_name"; then
1219
1312
  say "PRESERVED: changed target $_u_link"
1220
1313
  exit 3
1221
1314
  fi
@@ -1252,7 +1345,7 @@ undo() {
1252
1345
  _u_removed=0
1253
1346
  for _u_name in $st_created; do
1254
1347
  _u_link=$st_dest/$_u_name
1255
- if is_our_link "$_u_link" "$root/skills/$_u_name"; then
1348
+ if owns_link "$_u_link" "$_u_name"; then
1256
1349
  remove_link "$_u_link" || die 4 "could not remove $_u_link"
1257
1350
  _u_removed=$((_u_removed + 1))
1258
1351
  fi
@@ -73,7 +73,7 @@ except ValueError:
73
73
 
74
74
  CURATED_SKILLS = frozenset(
75
75
  {
76
- "nobrainer-codex-context",
76
+ "nobrainer-context",
77
77
  "nobrainer-skill-doctor",
78
78
  "nobrainer-autoimprove",
79
79
  "nobrainer-auto-fine-tune",
@@ -104,6 +104,7 @@ LEGACY_TO_CANONICAL = {
104
104
  "agents-restraint": "nobrainer-tech-flow",
105
105
  "codex-in-claude-code": "nobrainer-tech-flow",
106
106
  "code-autoresearch": "nobrainer-autoimprove",
107
+ "nobrainer-codex-context": "nobrainer-context",
107
108
  "deep-audit": "nobrainer-review",
108
109
  "deep-autoreview": "nobrainer-review",
109
110
  "deep-autoresearch": "nobrainer-autoimprove",
@@ -149,9 +150,11 @@ LEGACY_TO_CANONICAL = {
149
150
  "wiki-get": "nobrainer-wiki",
150
151
  "wiki-tidy": "nobrainer-wiki",
151
152
  }
152
- # Reddit integration was removed, not absorbed into a canonical skill. Keep its
153
- # owned-link migration, but a different source may legitimately use this name.
154
- RETIRED_LEGACY = frozenset({"nobrainer-reddit"})
153
+ # Names Flow no longer ships: Reddit integration was removed, and
154
+ # nobrainer-codex-context was renamed to nobrainer-context in 2.5.0 without an
155
+ # alias. Keep their owned-link migration, but another source may legitimately
156
+ # use these names, so a foreign target there is preserved, never removed.
157
+ RETIRED_LEGACY = frozenset({"nobrainer-codex-context", "nobrainer-reddit"})
155
158
  UNMAPPED_LEGACY = frozenset({"nobrainer-fast-audit"})
156
159
 
157
160
 
@@ -455,6 +458,10 @@ def legacy_name_for(target: Path, canonical: Path) -> str | None:
455
458
  return None
456
459
 
457
460
 
461
+ class ClaimRestoreError(RuntimeError):
462
+ """A changed entry was claimed but could not be restored to its original name."""
463
+
464
+
458
465
  def claim_legacy_link(
459
466
  target: Path, legacy_name: str
460
467
  ) -> tuple[Path, EntryFingerprint]:
@@ -484,7 +491,7 @@ def claim_legacy_link(
484
491
  try:
485
492
  restore_claim(target, claim)
486
493
  except RuntimeError as exc:
487
- raise RuntimeError(
494
+ raise ClaimRestoreError(
488
495
  f"legacy link changed after preflight; {exc}"
489
496
  ) from exc
490
497
  raise RuntimeError(
@@ -683,6 +690,17 @@ def parse_args() -> argparse.Namespace:
683
690
  "root-level layout and renamed aliases."
684
691
  ),
685
692
  )
693
+ parser.add_argument(
694
+ "--recorded-retired",
695
+ action="append",
696
+ default=[],
697
+ metavar="NAME",
698
+ help=(
699
+ "A retired name that the calling setup's record lists as created by it: "
700
+ "its exact link into this checkout no longer blocks installation and is "
701
+ "left in place for that setup to retire once its whole run has succeeded."
702
+ ),
703
+ )
686
704
  return parser.parse_args()
687
705
 
688
706
 
@@ -748,6 +766,14 @@ def main() -> int:
748
766
  if legacy_name in RETIRED_LEGACY
749
767
  else "conflict"
750
768
  )
769
+ if (
770
+ state == "legacy"
771
+ and legacy_name in RETIRED_LEGACY
772
+ and legacy_name in args.recorded_retired
773
+ ):
774
+ # The setup record proves the setup made this link. The setup retires it
775
+ # after its own run succeeds; this helper never deletes it.
776
+ state = "recorded"
751
777
  alias_plan.append((legacy_name, canonical_name, target, state))
752
778
  if state == "conflict" or (state == "legacy" and not args.migrate_legacy):
753
779
  conflicts.append(target)
@@ -774,6 +800,9 @@ def main() -> int:
774
800
  print(f"{action}: {name}: {source} -> {target}")
775
801
 
776
802
  for legacy_name, canonical_name, target, state in alias_plan:
803
+ if state == "recorded":
804
+ print(f"RETIRED_RECORDED: {legacy_name}: made by this setup; renamed {canonical_name}: {target}")
805
+ continue
777
806
  if state == "preserved":
778
807
  print(
779
808
  f"PRESERVED_FOREIGN: {legacy_name}: retired from nobrainer-tech-flow; "
@@ -107,6 +107,85 @@ def recorded_setting(client: str, home: Path, destination: object, target: objec
107
107
  return None
108
108
 
109
109
 
110
+ def recorded_skill(name: object) -> bool:
111
+ """A name a setup record may hold: a current module, or one a release retired."""
112
+
113
+ return isinstance(name, str) and (name in BY_SKILL or name in _sibling(INSTALL_SKILLS).RETIRED_LEGACY)
114
+
115
+
116
+ def owns_link(target: Path, name: str) -> bool:
117
+ """True when target is this checkout's link for name; a retired module's link dangles."""
118
+
119
+ if name in BY_SKILL:
120
+ return links_to(target, SKILLS_DIR / name)
121
+ return _sibling(INSTALL_SKILLS).is_exact_legacy_link(target, name)
122
+
123
+
124
+ def recorded_retired_args(record: dict | None) -> list[str]:
125
+ """install_skills.py flags for retired modules a setup record says this setup linked."""
126
+
127
+ names = (record or {}).get("created_skills", [])
128
+ return [arg for name in names if isinstance(name, str) and name not in BY_SKILL for arg in ("--recorded-retired", name)]
129
+
130
+
131
+ def claim_retired_links(destination: Path, record: dict | None) -> list[tuple[Path, Path, tuple]]:
132
+ """Move the retired links a record lists aside, verifying each moved entry.
133
+
134
+ The claim is install_skills.py's atomic claim-and-verify: an entry that changed after
135
+ the check is put back untouched. The claims are deleted only once the whole setup run
136
+ has succeeded, and put back on any failure before that.
137
+ """
138
+
139
+ skills = _sibling(INSTALL_SKILLS)
140
+ claims = []
141
+ try:
142
+ for name in (record or {}).get("created_skills", []):
143
+ if not isinstance(name, str) or name in BY_SKILL:
144
+ continue
145
+ target = destination / name
146
+ if not skills.is_exact_legacy_link(target, name):
147
+ continue
148
+ try:
149
+ claim, expected = skills.claim_legacy_link(target, name)
150
+ except skills.ClaimRestoreError:
151
+ raise
152
+ except RuntimeError as exc:
153
+ print(f"PRESERVED: {target} changed during the setup and is left as it is ({exc})")
154
+ continue
155
+ claims.append((target, claim, expected))
156
+ except (OSError, skills.ClaimRestoreError) as exc:
157
+ errors = restore_retired_links(claims)
158
+ if errors:
159
+ raise skills.ClaimRestoreError(f"{exc}; {'; '.join(errors)}") from exc
160
+ raise
161
+ return claims
162
+
163
+
164
+ def restore_retired_links(claims: list[tuple[Path, Path, tuple]]) -> list[str]:
165
+ """Put claimed retired links back; return what could not be restored."""
166
+
167
+ errors = []
168
+ for target, claim, expected in reversed(claims):
169
+ try:
170
+ # Moves the entry back without replacing a new one, and removes the private folder.
171
+ _sibling(INSTALL_SKILLS).restore_claim(target, claim, expected)
172
+ except (OSError, RuntimeError) as exc:
173
+ errors.append(f"retired link restore failed: {exc}")
174
+ return errors
175
+
176
+
177
+ def commit_retired_links(claims: list[tuple[Path, Path, tuple]]) -> None:
178
+ """Delete the claimed retired links once the setup run has succeeded."""
179
+
180
+ for target, claim, expected in claims:
181
+ if _sibling(INSTALL_SKILLS).entry_fingerprint(claim) == expected:
182
+ claim.unlink()
183
+ claim.parent.rmdir()
184
+ print(f"REMOVED_RETIRED: {target}")
185
+ else:
186
+ print(f"BACKUP_PRESERVED: the claimed retired link changed and is kept: {claim}")
187
+
188
+
110
189
  def links_to(target: Path, source: Path) -> bool:
111
190
  """True when target is a symlink resolving to source; loops and dangling links are not."""
112
191
 
@@ -144,7 +223,7 @@ ITEMS = (
144
223
  Item("14", "nobrainer-spec-driven-development", "Specifications", ("software-development", "product"), "define durable contracts for architectural changes"),
145
224
  Item("15", "nobrainer-rca", "Root-cause analysis", ("software-development", "operations"), "diagnose repeated or causally unclear failures"),
146
225
  Item("16", "nobrainer-team", "Capability-to-role design", ("software-development", "operations"), "assign available capabilities to bounded work"),
147
- Item("17", "nobrainer-codex-context", "Project context", ("software-development", "operations"), "inspect and maintain project instructions and context"),
226
+ Item("17", "nobrainer-context", "Project instructions", ("software-development", "operations"), "one tidy set of project instructions for every coding agent"),
148
227
  Item("18", "nobrainer-wiki", "Durable knowledge", ("research", "product", "operations"), "query and maintain a sourced project knowledge base"),
149
228
  )
150
229
  BY_ID = {item.id: item for item in ITEMS}
@@ -604,10 +683,10 @@ def run(command: list[str], hide: tuple[str, ...] = ()) -> tuple[int, str]:
604
683
 
605
684
  def remove_exact_created_links(destination: Path, names: list[str]) -> None:
606
685
  for name in names:
607
- if name not in BY_SKILL:
686
+ if not recorded_skill(name):
608
687
  continue
609
688
  target = destination / name
610
- if links_to(target, SKILLS_DIR / name):
689
+ if owns_link(target, name):
611
690
  target.unlink()
612
691
 
613
692
 
@@ -783,7 +862,7 @@ def read_state_file(path: Path) -> dict[str, object]:
783
862
  or not isinstance(loaded.get("destination"), str)
784
863
  or not isinstance(loaded.get("created_skills"), list)
785
864
  or not isinstance(loaded.get("profile"), dict)
786
- or any(not isinstance(name, str) or name not in BY_SKILL for name in loaded.get("created_skills", []))
865
+ or not all(recorded_skill(name) for name in loaded.get("created_skills", []))
787
866
  ):
788
867
  raise ValueError(f"refusing to overwrite unrecognized setup state: {path}")
789
868
  try:
@@ -997,7 +1076,7 @@ def rollback(args: argparse.Namespace, state_file: Path) -> int:
997
1076
  return 3
998
1077
  destination = Path(state["destination"])
999
1078
  created = state.get("created_skills", [])
1000
- if not isinstance(created, list) or len(created) != len(set(created)) or any(name not in BY_SKILL for name in created):
1079
+ if not isinstance(created, list) or len(created) != len(set(created)) or not all(recorded_skill(name) for name in created):
1001
1080
  print("ERROR: rollback state contains invalid skill ownership entries", file=sys.stderr)
1002
1081
  return 3
1003
1082
  try:
@@ -1066,7 +1145,7 @@ def rollback(args: argparse.Namespace, state_file: Path) -> int:
1066
1145
  for name in created:
1067
1146
  target = destination / name
1068
1147
  if target.is_symlink():
1069
- if not links_to(target, SKILLS_DIR / name):
1148
+ if not owns_link(target, name):
1070
1149
  print(f"PRESERVED: changed target {target}")
1071
1150
  return 3
1072
1151
  elif target.exists():
@@ -1086,8 +1165,7 @@ def rollback(args: argparse.Namespace, state_file: Path) -> int:
1086
1165
  removed: list[str] = []
1087
1166
  for name in created:
1088
1167
  target = destination / name
1089
- source = SKILLS_DIR / name
1090
- if links_to(target, source):
1168
+ if owns_link(target, name):
1091
1169
  target.unlink()
1092
1170
  removed.append(name)
1093
1171
  elif target.exists() or target.is_symlink():
@@ -1384,7 +1462,7 @@ def main(argv: list[str] | None = None) -> int:
1384
1462
  # Inspect every target before writing any of them. This keeps a later
1385
1463
  # conflict from leaving a partial selection installed.
1386
1464
  for name in install_names:
1387
- command = [sys.executable, str(ROOT / "scripts" / "install_skills.py"), "--client", args.client, "--dest", str(destination), "--skill", name]
1465
+ command = [sys.executable, str(ROOT / "scripts" / "install_skills.py"), "--client", args.client, "--dest", str(destination), "--skill", name, *recorded_retired_args(previous)]
1388
1466
  code, _ = run(command, hide=("NOTE: ",))
1389
1467
  if code:
1390
1468
  return code
@@ -1394,7 +1472,7 @@ def main(argv: list[str] | None = None) -> int:
1394
1472
  profile_preimage = read_file_preimage(profile_target)
1395
1473
  if args.apply:
1396
1474
  for name in install_names:
1397
- command = [sys.executable, str(ROOT / "scripts" / "install_skills.py"), "--client", args.client, "--dest", str(destination), "--skill", name, "--apply"]
1475
+ command = [sys.executable, str(ROOT / "scripts" / "install_skills.py"), "--client", args.client, "--dest", str(destination), "--skill", name, "--apply", *recorded_retired_args(previous)]
1398
1476
  code, _ = run(command, hide=("NOTE: ",))
1399
1477
  if code:
1400
1478
  remove_exact_created_links(destination, new_skills)
@@ -1428,7 +1506,9 @@ def main(argv: list[str] | None = None) -> int:
1428
1506
  "backup": recorded_backup,
1429
1507
  "written_sha256": hashlib.sha256(profile_target.read_bytes()).hexdigest(),
1430
1508
  }
1431
- created = sorted(set(previous.get("created_skills", [])) | {name for name in new_skills if (destination / name).is_symlink()})
1509
+ # A retired module's link is gone once retired; then the record drops its name.
1510
+ kept = {name for name in previous.get("created_skills", []) if name in BY_SKILL or owns_link(destination / name, name)}
1511
+ created = sorted(kept | {name for name in new_skills if (destination / name).is_symlink()})
1432
1512
  next_state = {"destination": str(destination), "created_skills": created, "profile": profile}
1433
1513
  # Folders an earlier run made stay in the record as recorded, links already resolved
1434
1514
  # then. Resolving them again would follow a link put in their place since, and hand the
@@ -1443,10 +1523,18 @@ def main(argv: list[str] | None = None) -> int:
1443
1523
  # a run makes a folder, so that its undo follows the rule it was made under.
1444
1524
  if created_dirs or not previous or "created_dirs" in previous:
1445
1525
  next_state["created_dirs"] = created_dirs
1526
+ # The retired links the record lists go last, after every other write, and come back
1527
+ # if the record cannot be saved.
1528
+ retired_claims = []
1446
1529
  try:
1530
+ retired_claims = claim_retired_links(destination, previous)
1531
+ retired_names = {target.name for target, _, _ in retired_claims}
1532
+ next_state["created_skills"] = [name for name in created if name not in retired_names]
1447
1533
  save_state(state, next_state)
1448
- except (OSError, TypeError, ValueError) as exc:
1534
+ except (OSError, RuntimeError, TypeError, ValueError) as exc:
1449
1535
  rollback_errors: list[str] = []
1536
+ if isinstance(exc, _sibling(INSTALL_SKILLS).ClaimRestoreError):
1537
+ rollback_errors.append(str(exc))
1450
1538
  if profile.get("target") and "APPLIED: " in profile_output:
1451
1539
  restore_preimage = profile_preimage
1452
1540
  if backup_match:
@@ -1483,6 +1571,7 @@ def main(argv: list[str] | None = None) -> int:
1483
1571
  remove_exact_created_links(destination, new_skills)
1484
1572
  except OSError as rollback_exc:
1485
1573
  rollback_errors.append(f"skill rollback failed: {rollback_exc}")
1574
+ rollback_errors.extend(restore_retired_links(retired_claims))
1486
1575
  remaining_links = [
1487
1576
  name
1488
1577
  for name in new_skills
@@ -1507,6 +1596,7 @@ def main(argv: list[str] | None = None) -> int:
1507
1596
  detail = f"{exc}; rollback incomplete: {'; '.join(rollback_errors)}" if rollback_errors else f"{exc}; applied changes were rolled back and read back"
1508
1597
  print(f"ERROR: setup state save failed: {detail}", file=sys.stderr)
1509
1598
  return 3 if rollback_errors else 2
1599
+ commit_retired_links(retired_claims)
1510
1600
  after = installed_ids(destination)
1511
1601
  actual_added = sorted(after - before)
1512
1602
  expected_added = sorted(BY_SKILL[name].id for name in new_skills)
@@ -18,7 +18,7 @@ LINK_RE = re.compile(r"\[[^\]]+\]\((?!https?://|#|mailto:)([^)]+)\)")
18
18
 
19
19
  SUITE = {
20
20
  "nobrainer-auto-fine-tune": "nb-auto-fine-tune",
21
- "nobrainer-codex-context": "nb-codex-context",
21
+ "nobrainer-context": "nb-context",
22
22
  "nobrainer-skill-doctor": "nb-skill-doctor",
23
23
  "nobrainer-tech-flow": "NBFlow",
24
24
  "nobrainer-team": "nb-team",
@@ -69,6 +69,7 @@ LEGACY = {
69
69
  "nb-write",
70
70
  "nobrainer-autopilot",
71
71
  "nobrainer-capture-lesson",
72
+ "nobrainer-codex-context",
72
73
  "nobrainer-continuous-improvement",
73
74
  "nobrainer-memory",
74
75
  "nobrainer-memory-memsearch",
@@ -0,0 +1,113 @@
1
+ ---
2
+ name: nobrainer-context
3
+ description: "Use when the owner says nb-context or asks to set up, audit or tidy one project's instructions for coding agents (AGENTS.md, CLAUDE.md, GEMINI.md, Cursor rules, Copilot instructions): find what each client loads and its size against documented limits, report duplicates and contradictions, then propose one source of truth with short pointers, applied only after approval and read back."
4
+ ---
5
+
6
+ # NoBrainer Context
7
+
8
+ Keep one tidy set of project instructions that every coding agent in use reads.
9
+ This module owns the project-instruction boundary of one repository: which files
10
+ each client loads, what they cost, where they repeat or disagree, and one
11
+ consolidation change. It does not own model selection, global client settings,
12
+ session transfer, goal tracking or the delivery workflow.
13
+
14
+ ## Select a mode
15
+
16
+ - `inspect` (default) - inventory and report. Do not write.
17
+ - `consolidate` - inspect, propose one source of truth with pointers, show the
18
+ exact preview, wait for explicit approval, apply project-local edits only,
19
+ then inspect again.
20
+ - `repair` - fix one known defect, such as a duplicate managed block, a dangling
21
+ import or a truncated Codex chain. Do not rewrite unrelated instructions.
22
+
23
+ A question gets `inspect`. Use `consolidate` only when the owner asks to tidy or
24
+ set up the project's instructions. Findings are evidence to report, not
25
+ permission to edit.
26
+
27
+ ## 1. Inventory what each client loads
28
+
29
+ Run the bundled read-only probe from the project with Python 3.11+, replacing
30
+ `<context-skill-dir>` with this module's directory:
31
+
32
+ ```bash
33
+ python3 <context-skill-dir>/scripts/context_inventory.py --json
34
+ ```
35
+
36
+ It lists, per client (Codex, Claude Code, Gemini CLI, Cursor, GitHub Copilot,
37
+ OpenCode), the files loaded at start, on demand or by import, with bytes, lines
38
+ and words; the Codex byte budget and truncation; files over documented line
39
+ guidance; byte-identical copies; lines repeated across files; imports of missing
40
+ files; settings or Codex configuration it cannot parse; and the Flow goal pointer
41
+ state. Instructions in folders below the current one are listed as on demand and
42
+ left out of the start-up totals. Add `--include-global` to include each client's
43
+ user-level file and, for Claude and Gemini, its recursive imports in the same
44
+ size, duplicate and goal-pointer checks. Claude's 200-line guidance applies only
45
+ to `CLAUDE.md` files discovered directly, not imported files, `AGENTS.md` fallbacks
46
+ or `.claude/rules/*.md` rules. Use `--client NAME` to narrow the report and
47
+ `--goal-file PATH` for an explicit goal file.
48
+ `scripts/codex_context.py --check --json` keeps the detailed Codex chain probe.
49
+
50
+ The probe applies the rules in [clients.md](references/clients.md). Only those
51
+ documented behaviours count; anything marked `UNVERIFIED` there is reported as
52
+ unknown, never as a limit. Ask which clients the team actually uses when the
53
+ files alone do not say, and drop the others from the report.
54
+
55
+ ## 2. Report duplicates and contradictions
56
+
57
+ Repeated lines come from the probe. Contradictions need reading: compare the
58
+ files a client loads together for different commands, package managers, test
59
+ gates, branch rules, languages or tone, and for a file that tells the agent to
60
+ read another file in words instead of importing it. Report each with exact
61
+ `file:line` pairs. This step is report-only.
62
+
63
+ ## 3. Propose one source of truth
64
+
65
+ Follow [consolidate.md](references/consolidate.md). The usual shape is
66
+ `AGENTS.md` as the source of truth, `CLAUDE.md` holding `@AGENTS.md` plus only
67
+ Claude-specific lines, and each other client's file reduced to a pointer or to
68
+ the lines that client alone needs. Show the full preview (new bytes or a diff
69
+ per file), what each client will load afterwards and the rollback, then wait
70
+ for explicit approval. Apply only inside the project. Never edit global
71
+ instruction files, `~/.codex/config.toml`, `~/.gemini/settings.json`, client
72
+ settings, credentials or plugin installs unless the owner asks for that
73
+ separate action.
74
+
75
+ ## 4. Read back
76
+
77
+ After a change, run the probe again and compare it with the preview. That is a
78
+ file-level readback. Mark `RUNTIME_READBACK: VERIFIED` only from a client's own
79
+ output that lists loaded files in a fresh session, as listed per client in
80
+ [clients.md](references/clients.md); otherwise `UNKNOWN`. A probe exit code or
81
+ an installed module does not prove a client followed the instructions.
82
+
83
+ ## 5. Goal pointer
84
+
85
+ When the project keeps a Flow goal file (`GOAL_ID:` block, see
86
+ [long-run state](../nobrainer-tech-flow/references/long-run-state.md)), check
87
+ that the instructions name the current one and no stale or missing goal path.
88
+ Propose the one-line pointer fix in the same preview. Goal content, status and
89
+ handoff stay with `nobrainer-tech-flow` and `nobrainer-sessions`.
90
+ If no active goals remain, the pointer state is `NONE`, including when completed
91
+ goals are still mentioned; a pointer to a missing file remains `STALE_POINTER`.
92
+
93
+ ## Closeout
94
+
95
+ ```text
96
+ CONTEXT: CURRENT | ATTENTION | UPDATED | BLOCKED
97
+ PROJECT_ROOT: <path or UNKNOWN>
98
+ CLIENTS: <client: loaded files and bytes, one per line>
99
+ LIMITS: <truncated or over-guidance files, or NONE>
100
+ DUPLICATES: <count and top file:line pairs, or NONE>
101
+ CONTRADICTIONS: <file:line pairs, or NONE>
102
+ SOURCE_OF_TRUTH: <file, or NOT_PROPOSED>
103
+ GOAL_POINTER: NONE | POINTED | NOT_POINTED | STALE_POINTER
104
+ PROJECT_WRITE: NONE | <files changed>
105
+ RUNTIME_READBACK: VERIFIED | UNKNOWN | UNSUPPORTED
106
+ OWNER_ACTION_REQUIRED: <one exact action or NONE>
107
+ ROLLBACK: <exact preimages or NOT_APPLICABLE>
108
+ ```
109
+
110
+ Route audits across many projects or skill portfolios to
111
+ `nobrainer-skill-doctor`, broader setup and delivery to `nobrainer-tech-flow`,
112
+ context transfer to `nobrainer-sessions` and release closeout to
113
+ `nobrainer-review`.