nobrainer-tech-flow 2.4.0 → 2.4.1

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.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "nobrainer-tech-flow",
10
10
  "description": "From task to done. nobrainer-tech-flow is one workflow of 18 modules for AI coding agents: it scopes the task, delegates ready work, checks the result and hands over the evidence, including what was not checked.",
11
- "version": "2.4.0",
11
+ "version": "2.4.1",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "NoBrainer Tech"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nobrainer-tech-flow",
3
3
  "description": "From task to done. nobrainer-tech-flow is one workflow of 18 modules for AI coding agents: it scopes the task, delegates ready work, checks the result and hands over the evidence, including what was not checked.",
4
- "version": "2.4.0",
4
+ "version": "2.4.1",
5
5
  "author": {
6
6
  "name": "NoBrainer Tech"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nobrainer-tech-flow",
3
3
  "description": "From task to done. nobrainer-tech-flow is one workflow of 18 modules for AI coding agents: it scopes the task, delegates ready work, checks the result and hands over the evidence, including what was not checked.",
4
- "version": "2.4.0",
4
+ "version": "2.4.1",
5
5
  "author": {
6
6
  "name": "NoBrainer Tech",
7
7
  "url": "https://nobrainer.tech"
package/AGENTS.md CHANGED
@@ -23,6 +23,7 @@ Respect host hierarchy; user instructions override skill guidelines. Apply [deli
23
23
  - `tests/` — deterministic and behavioral regression gates.
24
24
  - `docs/COMPATIBILITY.md` and `docs/TESTING.md` — proof boundaries.
25
25
  - `docs/SKILL_CURATION.md` — admission, ownership and retirement decisions.
26
+ - `docs/RELEASING.md`: releases.
26
27
 
27
28
  ## Portable skill contract
28
29
 
package/CLAUDE.md CHANGED
@@ -23,6 +23,7 @@ Respect host hierarchy; user instructions override skill guidelines. Apply [deli
23
23
  - `tests/` — deterministic and behavioral regression gates.
24
24
  - `docs/COMPATIBILITY.md` and `docs/TESTING.md` — proof boundaries.
25
25
  - `docs/SKILL_CURATION.md` — admission, ownership and retirement decisions.
26
+ - `docs/RELEASING.md`: releases.
26
27
 
27
28
  ## Portable skill contract
28
29
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <p align="center">
2
2
  <a href="https://nobrainer.tech">
3
- <img src="assets/nobrainer-tech-logo.svg" width="96" alt="nobrainer-tech-flow monogram">
3
+ <img src="assets/nobrainer-tech-logo.svg" width="96" alt="NoBrainer Tech logo">
4
4
  </a>
5
5
  </p>
6
6
 
@@ -534,9 +534,10 @@ and [Testing](docs/TESTING.md) for acceptance evidence.
534
534
  **What's new in 2.4.0:** nobrainer-tech-flow can ask before risky commands and
535
535
  shows whether its proof still matches your files. See [RELEASE-NOTES.md](RELEASE-NOTES.md).
536
536
 
537
- Current source version: **2.4.0**. Check the
537
+ Current source version: **2.4.1**, which fixes undo after a rearranged profile. Check the
538
538
  [latest published GitHub release](https://github.com/nobrainer-tech/nobrainer-tech-flow/releases/latest)
539
- for distribution and the [2.4.0 verification record](docs/releases/v2.4.0.md) for
539
+ for distribution and the [2.4.1](docs/releases/v2.4.1.md) and
540
+ [2.4.0 verification records](docs/releases/v2.4.0.md) for
540
541
  the current scope, reproducible checks and client-runtime limits. The unchanged command runner keeps its
541
542
  [v1.7.0 verification scope](docs/releases/v1.7.0.md). Source publication does not
542
543
  imply client marketplace discovery or improved model reasoning. The earlier
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="NBTF"><rect x="2" y="2" width="92" height="92" rx="22" fill="#2948B8"/><text x="18" y="43" fill="#fff" font-family="Arial,Helvetica,sans-serif" font-weight="800" font-size="31" letter-spacing="-2">NB</text><text x="18" y="76" fill="#fff" font-family="Arial,Helvetica,sans-serif" font-weight="800" font-size="31" letter-spacing="-2">TF</text><rect x="70" y="63" width="8" height="8" rx="1" fill="#FF9875"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M126 0H476A36 36 0 0 1 512 36V386A126 126 0 0 1 386 512H36A36 36 0 0 1 0 476V126A126 126 0 0 1 126 0Z" fill="#3B65F0"/><path d="M384 384H121A24 24 0 0 1 97 358A288 288 0 0 1 358 97A24 24 0 0 1 384 121Z" fill="#FFF7EC"/><rect x="240" y="240" width="104" height="104" fill="#F04A14"/></svg>
Binary file
package/docs/INSTALL.md CHANGED
@@ -14,9 +14,9 @@ upgraded merely because the new source is installed nearby.
14
14
  Node 18+ and Python 3.11+ are prerequisites. Preview the setup first:
15
15
 
16
16
  ```sh
17
- npx nobrainer-tech-flow@2.4.0 --client codex
18
- npx nobrainer-tech-flow@2.4.0 --client codex --apply
19
- npx nobrainer-tech-flow@2.4.0 --client codex --undo --apply
17
+ npx nobrainer-tech-flow@2.4.1 --client codex
18
+ npx nobrainer-tech-flow@2.4.1 --client codex --apply
19
+ npx nobrainer-tech-flow@2.4.1 --client codex --undo --apply
20
20
  ```
21
21
 
22
22
  Use claude for Claude Code, opencode or copilot for those clients. Add --home PATH
@@ -24,7 +24,7 @@ for an isolated profile. These commands become available once this version is
24
24
  published on npm; a source candidate is not registry availability.
25
25
 
26
26
  Application copies the release files into the chosen home directory's
27
- .nobrainer-tech-flow-npm/2.4.0 and verifies the copy. That folder sits beside
27
+ .nobrainer-tech-flow-npm/2.4.1 and verifies the copy. That folder sits beside
28
28
  ~/.nobrainer-tech-flow, where the Git installation suggests a clone, so cleaning
29
29
  the clone cannot delete the files the skill links point to. Releases before 2.3.2
30
30
  kept their copy in .nobrainer-tech-flow/npm; undo such a setup with the command of
@@ -0,0 +1,24 @@
1
+ # Releasing nobrainer-tech-flow
2
+
3
+ This repository is the one product to promote: stars, issues and links point here.
4
+ Every channel below carries the same version from the same tag, and its links
5
+ (homepage, repository, support) lead back here. A stable release is finished only
6
+ when each channel reads back that version, or the gap is reported:
7
+
8
+ 1. GitHub release from the annotated `vX.Y.Z` tag, with
9
+ `nobrainer-tech-flow-X.Y.Z-plugin.zip` (`scripts/build_plugin.py`) and the
10
+ `npm pack` tarball built from the merge commit; download each asset and compare its
11
+ SHA-256 with the digest of that same file before upload.
12
+ 2. npm: run the `Publish npm` workflow from the tag, then read back
13
+ `npm view nobrainer-tech-flow version`.
14
+ 3. Claude Code and Codex marketplaces in this repository: they follow the default
15
+ branch, so install `nobrainer-tech-flow@nobrainer-tech` in a throwaway profile while
16
+ `main` still equals the tag, and read back the version and the installed commit.
17
+ 4. `nobrainer-tech/nobrainer-tech-flow-plugin`: export the release ZIP there
18
+ through a PR, update `SOURCE.json`, run its unmodified
19
+ `_maintainers/verify_release.py`, then release it. It is the hook-free copy that
20
+ the Claude directory tracks, not a second project to promote.
21
+ 5. Claude directory (claude.ai/directory/manage) picks up that repository's
22
+ default branch; OpenAI plugin directory (platform.openai.com/plugins) takes the
23
+ native ZIP upload. Both submissions and publication clicks are owner-gated.
24
+ 6. Changelog on https://nobrainer.tech/flow/changelog/ after owner preview.
@@ -0,0 +1,63 @@
1
+ # nobrainer-tech-flow 2.4.1: undo after a rearranged profile
2
+
3
+ Release date: 2026-10-19.
4
+
5
+ Two fixes to the setup record from an independent review of the 2.3.2 installer
6
+ (issue #82). In both cases the undo used to stop with exit 3 and change nothing, so no
7
+ data was lost, but the skill links stayed installed until the record was fixed by hand.
8
+
9
+ ## What changes
10
+
11
+ - **Undo works after a link joins two folders the setup made.** After a Codex setup on
12
+ a fresh profile, moving `~/.agents/skills` to `~/.codex/skills` and replacing
13
+ `~/.agents` with a link to `~/.codex` makes two entries of `created_dirs` lead to
14
+ one folder. `install.sh`, `install.py` and the guided `--rollback` refused that record
15
+ as listing a folder twice. They now look for a repeated entry by its spelling in the
16
+ record, so a link added after the setup no longer counts as a repeat. The undo
17
+ removes the links, leaves the `~/.agents` link and everything in `~/.codex` as they
18
+ are, and prints a `PRESERVED` line for the folder that now lies behind the link. A
19
+ record that lists the same path twice is still refused with exit 3. When two
20
+ different entries lead to one folder, the record cannot show which of them the setup
21
+ made, so the undo removes neither: each one stays with a `PRESERVED` line, while the
22
+ links and the other recorded folders are still removed.
23
+ - **A folder whose real path holds a line break stops the setup before it writes.**
24
+ The record lists each folder the setup makes with the links on the way resolved.
25
+ When a link on the way leads to a folder whose name holds a line break or another
26
+ control character (for example `~/.claude` linked to such a folder), `install.sh`
27
+ wrote that path into the record split over two lines, so apply exited 0 and the
28
+ undo later refused the record. The Python installer wrote such a path for a folder
29
+ behind a link that is not on the skills path (for example `CODEX_HOME` set to a
30
+ folder still to be made behind such a link). Both installers now refuse the apply
31
+ with exit 2 and an error that names the folder, before anything is written, as they
32
+ already did for a path written with such a character. `install.sh` also keeps a
33
+ line break at the end of a folder name when it resolves links; it used to drop it.
34
+
35
+ ## Verification record
36
+
37
+ Local checks (macOS, Python 3.14.5):
38
+
39
+ - Two new tests in `tests.test_shell_installer` run each case with `install.sh` under
40
+ `/bin/sh`, dash, bash in POSIX mode and zsh emulating sh, and with `install.py`.
41
+ Against the 2.4.0 scripts they failed in 23 subtests: every `install.sh` run of both
42
+ cases, the `install.py` undo of the rearranged profile, and the `install.py` apply
43
+ with `CODEX_HOME` behind a link. The `install.py` apply with `~/.claude` linked to
44
+ such a folder was already refused. Without the trailing line break fix, 8 `install.sh`
45
+ subtests for a folder name that ends in a line break still failed.
46
+ - The case "a folder listed under two spellings" was removed from the test of damaged
47
+ records, because such a record is now accepted; the case of a folder listed twice
48
+ stays.
49
+ - A third test, from a review of this fix, lists an existing empty home under its real
50
+ path and through a link to it. The undo exits 0 and the home stays in all 5 runs; with
51
+ the first version of this fix, the undo removed the home in all 5.
52
+ - `python3 -m unittest tests.test_shell_installer tests.test_one_command_installer
53
+ tests.test_guided_onboarding tests.test_installer tests.test_install_guards`: 205
54
+ tests passed, 2 skipped.
55
+ - `sh -n` and `dash -n` on `scripts/install.sh`, `shellcheck -S error` and
56
+ `git diff --check` passed.
57
+
58
+ The line break case is not tested on Windows, which does not allow such a file name.
59
+
60
+ ## Scope
61
+
62
+ This is an installer fix. The record format, the module set and the instruction
63
+ block do not change.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nobrainer-tech-flow",
3
3
  "description": "From task to done. nobrainer-tech-flow is one workflow of 18 modules for AI coding agents: it scopes the task, delegates ready work, checks the result and hands over the evidence, including what was not checked.",
4
- "version": "2.4.0",
4
+ "version": "2.4.1",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nobrainer-tech-flow",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "From task to done. nobrainer-tech-flow is one workflow of 18 modules for AI coding agents: it scopes the task, delegates ready work, checks the result and hands over the evidence, including what was not checked.",
5
5
  "type": "module",
6
6
  "main": ".opencode/plugins/nobrainer-tech-skills.js",
package/plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "nobrainer-tech-flow",
4
4
  "description": "From task to done. nobrainer-tech-flow is one workflow of 18 modules for AI coding agents: it scopes the task, delegates ready work, checks the result and hands over the evidence, including what was not checked.",
5
- "version": "2.4.0",
5
+ "version": "2.4.1",
6
6
  "author": {
7
7
  "name": "NoBrainer Tech",
8
8
  "url": "https://nobrainer.tech"
@@ -169,7 +169,9 @@ physical() {
169
169
  return
170
170
  fi
171
171
  done
172
- _p_head=$(cd -P "$_p_head" 2>/dev/null && pwd -P) || _p_head=$1 _p_tail=
172
+ # The slash keeps a trailing line break of a folder name across the substitution.
173
+ _p_head=$(cd -P "$_p_head" 2>/dev/null && printf '%s/' "$PWD") || _p_head=$1/ _p_tail=
174
+ _p_head=${_p_head%/}
173
175
  printf '%s%s\n' "${_p_head%/}" "$_p_tail"
174
176
  }
175
177
 
@@ -543,7 +545,9 @@ under_link() {
543
545
  # The folders a record lists must be ones the setup can have made: each an absolute,
544
546
  # plain path listed once, that is, or lies above, the skills folder, the folder of the
545
547
  # instruction file or the folder of the record. Anything else is a damaged record that an undo must not act on. A path
546
- # may be spelled with or without the links on the way, so both spellings count.
548
+ # may be spelled with or without the links on the way, so both spellings count. Two entries
549
+ # may still lead to one folder: a link put in place of one of them since the setup does that.
550
+ # The undo removes no folder of such a group.
547
551
  above() {
548
552
  case "$2/" in "$1"/*) return 0 ;; esac
549
553
  return 1
@@ -591,9 +595,9 @@ check_dirs() {
591
595
  fi
592
596
  [ -n "$_k_ok" ] || exit 1
593
597
  case $_k_seen in *"
594
- $_k_real
598
+ $_k_dir
595
599
  "*) exit 1 ;; esac
596
- _k_seen="$_k_seen$_k_real
600
+ _k_seen="$_k_seen$_k_dir
597
601
  "
598
602
  done
599
603
  }
@@ -922,6 +926,16 @@ install() {
922
926
  sha256 /dev/null >/dev/null || die 2 "this script needs sha256sum, shasum or openssl to record the setup"
923
927
 
924
928
  created= dirs_to_make=
929
+ # A folder this run makes is recorded as it really is, links resolved. A line break or a
930
+ # control character in that form would leave a record no undo can read, so such a run
931
+ # stops before anything is written.
932
+ note_dirs "$home"
933
+ if [ "$link_count" -gt 0 ]; then note_dirs "$skills_dir"; fi
934
+ if [ "$file_status" != present ] && [ "$inherit" = 0 ]; then note_dirs "${target%/*}"; fi
935
+ printf '%s' "$dirs_to_make" | while IFS= read -r _a_dir; do
936
+ reject_control "$(physical "$_a_dir")" "the real path of $_a_dir"
937
+ done || exit 2
938
+ dirs_to_make=
925
939
  # The record's folder is made before anything else is written: when it cannot be made,
926
940
  # the run stops here instead of leaving links and a block that no record describes. It
927
941
  # counts among the folders the setup made: a home that does not exist yet is made for
@@ -1283,9 +1297,23 @@ undo() {
1283
1297
  rm -f "$state"
1284
1298
  # The record is gone, so the folder it sat in may be empty now. A folder that was there
1285
1299
  # before the setup is not listed, and a folder that holds anything stays.
1300
+ # Two entries that lead to one folder cannot both be what the setup made, and the record
1301
+ # cannot say which one is, so no entry of such a group is removed. The groups are found
1302
+ # before any folder goes.
1286
1303
  _u_gone=
1287
1304
  if [ -n "$st_dirs" ]; then
1305
+ _u_dupes=$(printf '%s' "$st_dirs" | while IFS= read -r _u_dir; do physical "$_u_dir"; done | LC_ALL=C sort | uniq -d)
1306
+ _u_shared=
1307
+ if [ -n "$_u_dupes" ]; then
1308
+ _u_shared=$(printf '%s' "$st_dirs" | while IFS= read -r _u_dir; do
1309
+ if printf '%s\n' "$_u_dupes" | grep -q -x -F -e "$(physical "$_u_dir")"; then printf '%s\n' "$_u_dir"; fi
1310
+ done)
1311
+ fi
1288
1312
  _u_gone=$(printf '%s' "$st_dirs" | LC_ALL=C sort -r | while IFS= read -r _u_dir; do
1313
+ if [ -n "$_u_shared" ] && printf '%s\n' "$_u_shared" | grep -q -x -F -e "$_u_dir"; then
1314
+ printf 'PRESERVED: %s: another folder in the record leads to the same folder, so it is left as it is\n' "$_u_dir"
1315
+ continue
1316
+ fi
1289
1317
  if [ ! -d "$_u_dir" ] || [ -L "$_u_dir" ]; then continue; fi
1290
1318
  if under_link "$_u_dir"; then
1291
1319
  printf 'PRESERVED: %s: a folder above it is a link now, so it is left as it is\n' "$_u_dir"
@@ -651,6 +651,8 @@ def recorded_directories(state: dict[str, object], record: Path) -> list[Path]:
651
651
  A path may be spelled with or without the links on the way, so both spellings count. A
652
652
  folder the setup made is listed as it really is, and a base spelled with ``..`` reaches
653
653
  it on the way without lying below it, so the real form of each folder on that way counts.
654
+ An entry may be listed once. Two entries may still lead to one folder: a link put in place
655
+ of one of them since the setup does that. The undo removes no folder of such a group.
654
656
  """
655
657
 
656
658
  raw = state.get("created_dirs", [])
@@ -675,9 +677,9 @@ def recorded_directories(state: dict[str, object], record: Path) -> list[Path]:
675
677
  or not any(form == place or form in place.parents for form in forms for place in places)
676
678
  ):
677
679
  raise ValueError(f"created_dirs names a folder this setup cannot have created: {item}")
678
- if forms[1] in seen:
680
+ if path in seen:
679
681
  raise ValueError("created_dirs must list each folder the setup created once")
680
- seen.add(forms[1])
682
+ seen.add(path)
681
683
  directories.append(path)
682
684
  return directories
683
685
 
@@ -698,18 +700,26 @@ def is_link(path: Path) -> bool:
698
700
  return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0))
699
701
 
700
702
 
701
- def remove_empty_directories(directories: list[Path]) -> tuple[list[Path], list[Path]]:
703
+ def remove_empty_directories(directories: list[Path]) -> tuple[list[Path], list[Path], list[Path]]:
702
704
  """Remove each of these folders that is still empty, innermost first.
703
705
 
704
- Returns the folders removed and the folders kept because a folder above them is a
705
- link now. The record lists each folder with the links on the way resolved, so such a
706
- link means the profile was rearranged since the setup, and removing the folder would
707
- act on another tree.
706
+ Returns the folders removed, the folders kept because a folder above them is a link
707
+ now, and the folders kept because another entry leads to the same folder. The record
708
+ lists each folder with the links on the way resolved, so such a link means the profile
709
+ was rearranged since the setup, and removing the folder would act on another tree. Two
710
+ entries that lead to one folder cannot both be what the setup made, and the record
711
+ cannot say which one is, so neither is removed.
708
712
  """
709
713
 
714
+ real = [physical_path(path) for path in directories]
715
+ shared = {path for path, form in zip(directories, real) if real.count(form) > 1}
710
716
  removed: list[Path] = []
711
717
  kept: list[Path] = []
718
+ kept_shared: list[Path] = []
712
719
  for path in sorted(directories, key=lambda item: len(item.parts), reverse=True):
720
+ if path in shared:
721
+ kept_shared.append(path)
722
+ continue
713
723
  try:
714
724
  if is_link(path) or not path.is_dir():
715
725
  continue
@@ -720,7 +730,7 @@ def remove_empty_directories(directories: list[Path]) -> tuple[list[Path], list[
720
730
  except OSError:
721
731
  continue
722
732
  removed.append(path)
723
- return removed, kept
733
+ return removed, kept, kept_shared
724
734
 
725
735
 
726
736
  def state_path(home: Path, override: Path | None, client: str) -> Path:
@@ -1126,11 +1136,13 @@ def rollback(args: argparse.Namespace, state_file: Path) -> int:
1126
1136
  print(f"ROLLBACK_READBACK: removed={','.join(removed) or 'none'}; profile=preserved")
1127
1137
  return 3
1128
1138
  state_file.unlink()
1129
- removed_dirs, kept_dirs = remove_empty_directories(directories)
1139
+ removed_dirs, kept_dirs, shared_dirs = remove_empty_directories(directories)
1130
1140
  for directory in removed_dirs:
1131
1141
  print(f"REMOVED_DIR: {directory}")
1132
1142
  for directory in kept_dirs:
1133
1143
  print(f"PRESERVED: {directory}: a folder above it is a link now, so it is left as it is")
1144
+ for directory in shared_dirs:
1145
+ print(f"PRESERVED: {directory}: another folder in the record leads to the same folder, so it is left as it is")
1134
1146
  if "created_dirs" not in state:
1135
1147
  # A record from before the folder list still gets its skills folder removed when empty.
1136
1148
  # One with the list says which folders the setup made, so no other is touched.
@@ -1366,6 +1378,9 @@ def main(argv: list[str] | None = None) -> int:
1366
1378
  # The record's own folder counts: a home that does not exist yet is made for it alone
1367
1379
  # when a client variable puts the skills and the instructions elsewhere.
1368
1380
  new_directories = missing_directories(destination, profile_target.parent, state.parent)
1381
+ # They are recorded as they really are, links resolved, and a path the record cannot
1382
+ # hold stops the run here, before anything is written.
1383
+ require_utf8(*(physical_path(path) for path in new_directories))
1369
1384
  # Inspect every target before writing any of them. This keeps a later
1370
1385
  # conflict from leaving a partial selection installed.
1371
1386
  for name in install_names:
@@ -1 +1 @@
1
- 2.4.0
1
+ 2.4.1