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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +7 -5
- package/docs/INSTALL.md +9 -6
- package/docs/MIGRATION_TO_FLOW.md +14 -0
- package/docs/SKILL_CURATION.md +8 -2
- package/docs/releases/v2.5.0.md +190 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install.py +13 -6
- package/scripts/install.sh +105 -12
- package/scripts/install_skills.py +34 -5
- package/scripts/recommend_flow_setup.py +102 -12
- package/scripts/validate_skills.py +2 -1
- package/skills/nobrainer-context/SKILL.md +113 -0
- package/skills/nobrainer-context/references/clients.md +115 -0
- package/skills/nobrainer-context/references/consolidate.md +66 -0
- package/skills/nobrainer-context/scripts/context_inventory.py +425 -0
- package/skills/nobrainer-skill-doctor/SKILL.md +4 -4
- package/skills/nobrainer-tech-flow/VERSION +1 -1
- package/skills/nobrainer-tech-flow/references/optional-decisions.md +5 -3
- package/skills/nobrainer-tech-flow/references/routing.md +1 -1
- package/skills/nobrainer-tech-flow/scripts/typed_decisions.py +20 -9
- package/skills/nobrainer-codex-context/SKILL.md +0 -111
- /package/skills/{nobrainer-codex-context → nobrainer-context}/scripts/codex_context.py +0 -0
package/scripts/install.sh
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
|
|
990
|
-
|
|
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 !
|
|
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
|
|
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-
|
|
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,
|
|
153
|
-
#
|
|
154
|
-
|
|
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
|
|
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-
|
|
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
|
|
686
|
+
if not recorded_skill(name):
|
|
608
687
|
continue
|
|
609
688
|
target = destination / name
|
|
610
|
-
if
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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`.
|