impoved 00_Project_Structure.html core_developer_branch
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Sat, 7 Mar 2026 13:42:41 +0000 (13:42 +0000)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Sat, 7 Mar 2026 13:42:41 +0000 (13:42 +0000)
administrator/document/00_Project_Structure.html
shared/authored/scratchpad [deleted file]
shared/authored/setup [deleted file]
shared/authored/style/.gitkeep [deleted file]
shared/authored/version [deleted file]
shared/tool/scratchpad [new file with mode: 0755]
shared/tool/setup [new file with mode: 0644]
shared/tool/style/.gitkeep [new file with mode: 0644]
shared/tool/version [new file with mode: 0755]

index 99cf663..2a56aea 100644 (file)
@@ -40,7 +40,7 @@
        </p>
 
       <p>
-        A person takes on a role by sourcing the top-level setup script and providing the target role. In a bash shell to take on the role of <RT-term>developer</RT-term>  the command is: <RT-code>. setup developer</RT-code>,  or <RT-code>source setup developer</RT-code>. As of this writing, the supported roles are: <strong>administrator</strong>, <strong>consumer</strong>, <strong>developer</strong>, and <strong>tester</strong>.
+        A person takes on a role by sourcing the top-level setup script and providing the target role. In a bash shell to take on the role of <RT-term>developer</RT-term>  the command is, for example: <RT-code>. setup developer</RT-code>,  or <RT-code>source setup developer</RT-code>. As of this writing, the supported roles are: <strong>administrator</strong>, <strong>consumer</strong>, <strong>developer</strong>, and <strong>tester</strong>.
       </p>
 
       <p>
@@ -48,7 +48,7 @@
       </p>
 
       <ul>
-        <li>Sources the project-wide setup (<RT-code>shared/authored/setup</RT-code>) to establish the core environment variables (e.g., <RT-code>REPO_HOME</RT-code> and <RT-code>PROJECT</RT-code>).</li>
+        <li>Sources the project-wide setup (<RT-code>shared/authored/setup</RT-code>) to establish the core environment variables (e.g. <RT-code>REPO_HOME</RT-code> and <RT-code>PROJECT</RT-code>).</li>
         <li>Configures the <RT-code>PATH</RT-code> to include shared tools, library environments, and the specific <RT-code>&lt;role&gt;/tool</RT-code> directory.</li>
         <li>Changes the working directory into the specified role's workspace.</li>
         <li>Sources the <RT-code>&lt;role&gt;/tool/setup</RT-code> script. While the earlier steps apply the standard Harmony skeleton setup, this final step applies the role setup that is customized for this specific project.</li>
         <li><RT-code>shared/document/</RT-code> : Documentation on installing and configuring shared tools.</li>
       </ul>
 
-      <h1>Key concepts</h1>
-      <h2>Authored vs. Made</h2>
+      <h1>Authored, made, scratchpad, third_party, inherited, customizations</h1>
       <p>
-        Harmony divides the world into two categories:
+        Files found in a directory named <RT-code>authored</RT-code> were written by project team members. They did not come with the Harmony skeleton, nor with the installation of other software. The project tools only read from <RT-code>authored</RT-code> directories. Typically these files constitute the intellectual property of a project.
+      </p>
+      <p>
+        Files found in a directory named <RT-code>made</RT-code> were generated by tools in the project. Authors should not edit these files, nor create new files in such a directory, as the directory content is under control of the tools. If a made file requires editing, it is best to find the source of the file, and edit at that point.
+      </p>
+      <p>
+        Files found in a directory named <RT-code>scratchpad</RT-code> are not tracked. Hence, a <RT-code>git clone</RT-code> will always return empty <RT-code>scratchpad</RT-code> directories. (Currently this is accomplished by putting a <RT-code>.gitignore</RT-code> file in every <RT-code>scratchpad</RT-code> directory.) It is common for tools to place intermediate files on a scratchpad. It is also common for files to be staged on a scratchpad. Tools play nice and use subdirectories on the pad, so a person can use a scratchpad as a temporary directory for files. There is a tool that comes with the Harmony skeleton with the same name, <RT-code>scratchpad</RT-code>, that is used to maintain scratchpads. Note that one of its commands is <RT-code>clear</RT-code> which deletes everything on a scratchpad.
+      </p>
+      <p>
+        Third party software is installed under <RT-code>shared/third_party</RT-code>.
+      </p>
+      <p>
+        Other files are said to be <RT-term>inherited</RT-term>, or to be <RT-term>customizations</RT-term>.
+      </p>
+      <p>
+        If an authored file must be added to a project in a location other than an <RT-code>authored</RT-code> directory, it is best to create and edit the file in an <RT-code>authored</RT-code> directory, then to create a symbolic link that points back to it from that other location.
       </p>
-      <ul>
-        <li><strong>Authored:</strong> Human-written (or AI-written) source files, docs, and design notes.</li>
-        <li><strong>Made:</strong> Tool-produced binaries, generated sources, and intermediates.</li>
-      </ul>
       <p>
-        This separation protects authored material from accidental overwrite and makes build artifacts fully disposable.
+        This approach makes it clear where to find team work product, what is generated by the tools in the project, and what was 'installed' with the project.
       </p>
 
       <h1>Top-Level repository layout</h1>
         <li><RT-code>shared/</RT-code> : Shared ecosystem tools and global environments.</li>
       </ul>
 
-      <h1>The administrator work area</h1>
+      <h2>The administrator work area</h2>
       <p>
         This directory holds the tools and documentation used to manage the project as a whole. It includes the HTML documentation for the project ontology and workflow, as well as project-local tools utilized by the administrator to maintain the Harmony skeleton.
       </p>
 
-      <h1>The developer work area</h1>
+      <h2>The developer work area</h2>
       <p>
         This directory is entered by first going to the top-level directory of the project, then sourcing <RT-code>. setup developer</RT-code>.
       </p>
         <li><RT-code>tool/</RT-code> : Developer-specific tools (like the release and make scripts).</li>
       </ul>
 
-      <h1>The tester work area</h1>
+      <h2>The tester work area</h2>
       <p>
         This directory is dedicated to formal testing, including regression suites. While a developer can run and keep informal spot tests in their <RT-code>experiment/</RT-code> directory, any experiment promoted to a formal test is moved here. This enforces the boundary between writing code and validating it.
       </p>
 
-      <h1>The shared tree</h1>
+      <h2>The shared tree</h2>
       <p>
         This directory contains ecosystem tools and global environments available to all roles. This includes shared authored tools, as well as third-party installations (like Python virtual environments or compilers) required by the project.
       </p>
 
-      <h1>The consumer tree</h1>
+      <h2>The consumer tree</h2>
       <p>
         The <RT-code>consumer/release/</RT-code> tree is where developers put work product that is ready to be consumed. The entire <RT-code>consumer/release</RT-code> directory is git-ignored and treated as a transient deployment target.
       </p>
         <li><strong>Project Release:</strong> A project is released when the tester and other team members determine the code in the <RT-code>consumer/release/</RT-code> directory is fully validated and ready for end users. This is enacted by creating a release branch on the version control system (e.g., GitHub), not by moving files to another directory.</li>
       </ul>
 
-      <h2>Directory names</h2>
+      <h1>Directory names</h1>
       <p>
         Each directory name is a <RT-term>property</RT-term> shared among the files contained within it. Hence, a full path forms a semantic sentence describing each file within it.
       </p>
         In the Unix file system, a person must choose a single string to use as the directory name. Hence, we typically choose a primary property, though when needed, we use multiple property names separated by an underscore.
       </p>
 
-      <h1>The version 2.2  Harmony directory tree</h1>
+      <h1>The version 2.2 Harmony directory tree</h1>
       <RT-code>
       2026-03-07 10:38:58 Z [Harmony:administrator] Thomas_developer@StanleyPark
       §/home/Thomas/subu_data/developer/project§
diff --git a/shared/authored/scratchpad b/shared/authored/scratchpad
deleted file mode 100755 (executable)
index f14f140..0000000
+++ /dev/null
@@ -1,225 +0,0 @@
-#!/usr/bin/env -S python3 -B
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-import os, sys, shutil, stat, pwd, grp, subprocess
-
-HELP = """usage: scratchpad {ls|clear|help|make|write|size|find|lock|unlock} [ARGS]
-  ls               List scratchpad in an indented tree with perms and owner (quiet if missing).
-  clear            Remove all contents of scratchpad/ except top-level .gitignore.
-  clear NAME       Remove scratchpad/NAME only.
-  make [NAME]      Ensure scratchpad/ exists with .gitignore; with NAME, mkdir scratchpad/NAME.
-  write SRC [DST]  Copy file/dir SRC into scratchpad (to DST if given; parents created).
-  size             Print 'empty' if only .gitignore; else total bytes and item count.
-  find [OPTS...]   Run system 'find' rooted at scratchpad/ with OPTS (omit literal 'scratchpad').
-  lock PATH...     Attempt 'chattr +i' on given paths under scratchpad/ (no state kept).
-  unlock PATH...   Attempt 'chattr -i' on given paths under scratchpad/.
-
-Examples:
-  scratchpad make
-  scratchpad write ~/Downloads/test.tar.gz
-  scratchpad find -type f -mtime +30 -print   # files older than 30 days
-  scratchpad lock some/dir important.txt
-  scratchpad unlock some/dir important.txt
-"""
-
-CWD = os.getcwd()
-SP = os.path.join(CWD, "scratchpad")
-GITIGNORE = os.path.join(SP, ".gitignore")
-
-def have_sp() -> bool:
-  return os.path.isdir(SP)
-
-def ensure_sp():
-  os.makedirs(SP, exist_ok=True)
-  ensure_gitignore()
-
-def ensure_gitignore():
-  os.makedirs(SP, exist_ok=True)
-  if not os.path.isfile(GITIGNORE):
-    with open(GITIGNORE, "w", encoding="utf-8") as f:
-      f.write("*\n!.gitignore\n")
-
-def filemode(mode: int) -> str:
-  try:
-    return stat.filemode(mode)
-  except Exception:
-    return oct(mode & 0o777)
-
-def owner_group(st) -> str:
-  try:
-    return f"{pwd.getpwuid(st.st_uid).pw_name}:{grp.getgrgid(st.st_gid).gr_name}"
-  except Exception:
-    return f"{st.st_uid}:{st.st_gid}"
-
-def rel_depth(base: str, root: str) -> int:
-  rel = os.path.relpath(base, root)
-  return 0 if rel == "." else rel.count(os.sep) + 1
-
-def ls_tree(root: str) -> None:
-  if not have_sp():
-    return
-  print("scratchpad/")
-
-  def walk(path: str, indent: str, is_root: bool) -> None:
-    try:
-      it = list(os.scandir(path))
-    except FileNotFoundError:
-      return
-
-    dirs  = [e for e in it if e.is_dir(follow_symlinks=False)]
-    files = [e for e in it if not e.is_dir(follow_symlinks=False)]
-    dirs.sort(key=lambda e: e.name)
-    files.sort(key=lambda e: e.name)
-
-    if is_root:
-      # 1) root-level hidden files first
-      for f in (e for e in files if e.name.startswith(".")):
-        st = os.lstat(f.path)
-        print(f"{filemode(st.st_mode)}  {owner_group(st)}  {indent}{f.name}")
-      # 2) then directories (and recurse so children sit under the parent)
-      for d in dirs:
-        st = os.lstat(d.path)
-        print(f"{filemode(st.st_mode)}  {owner_group(st)}  {indent}{d.name}/")
-        walk(d.path, indent + '  ', False)
-      # 3) then non-hidden files
-      for f in (e for e in files if not e.name.startswith(".")):
-        st = os.lstat(f.path)
-        print(f"{filemode(st.st_mode)}  {owner_group(st)}  {indent}{f.name}")
-    else:
-      # subdirs: keep previous order (dirs first, then files; dotfiles naturally sort first)
-      for d in dirs:
-        st = os.lstat(d.path)
-        print(f"{filemode(st.st_mode)}  {owner_group(st)}  {indent}{d.name}/")
-        walk(d.path, indent + '  ', False)
-      for f in files:
-        st = os.lstat(f.path)
-        print(f"{filemode(st.st_mode)}  {owner_group(st)}  {indent}{f.name}")
-
-  walk(root, "  ", True)
-
-
-def clear_all() -> None:
-  if not have_sp():
-    return
-  for name in os.listdir(SP):
-    p = os.path.join(SP, name)
-    if name == ".gitignore" and os.path.isfile(p):
-      continue  # preserve only top-level .gitignore
-    if os.path.isdir(p) and not os.path.islink(p):
-      shutil.rmtree(p, ignore_errors=True)
-    else:
-      try: os.unlink(p)
-      except FileNotFoundError: pass
-
-def clear_subdir(sub: str) -> None:
-  if not have_sp():
-    return
-  target = os.path.normpath(os.path.join(SP, sub))
-  try:
-    if os.path.commonpath([SP]) != os.path.commonpath([SP, target]):
-      return
-  except Exception:
-    return
-  if os.path.isdir(target) and not os.path.islink(target):
-    shutil.rmtree(target, ignore_errors=True)
-
-def cmd_make(args):
-  ensure_sp()
-  if args:
-    os.makedirs(os.path.join(SP, args[0]), exist_ok=True)
-
-def cmd_write(args):
-  if len(args) < 1:
-    print(HELP); return
-  if not have_sp():
-    ensure_sp()
-  src = args[0]
-  dst = args[1] if len(args) >= 2 else (os.path.basename(src.rstrip(os.sep)) or "untitled")
-  dst_path = os.path.normpath(os.path.join(SP, dst))
-  try:
-    if os.path.commonpath([SP]) != os.path.commonpath([SP, dst_path]):
-      print("refusing to write outside scratchpad", file=sys.stderr); return
-  except Exception:
-    print("invalid destination", file=sys.stderr); return
-  os.makedirs(os.path.dirname(dst_path), exist_ok=True)
-  if os.path.isdir(src):
-    if os.path.exists(dst_path):
-      shutil.rmtree(dst_path, ignore_errors=True)
-    shutil.copytree(src, dst_path, dirs_exist_ok=False)
-  else:
-    shutil.copy2(src, dst_path)
-
-def cmd_size():
-  if not have_sp():
-    return
-  names = os.listdir(SP)
-  if [n for n in names if n != ".gitignore"] == []:
-    print("empty"); return
-  total = 0; count = 0
-  for base, dirs, files in os.walk(SP):
-    for fn in files:
-      if fn == ".gitignore": 
-        continue
-      p = os.path.join(base, fn)
-      try:
-        total += os.path.getsize(p); count += 1
-      except OSError:
-        pass
-  print(f"bytes={total} items={count}")
-
-def cmd_find(args):
-  if not have_sp():
-    return
-  try:
-    subprocess.run(["find", SP] + args, check=False)
-  except FileNotFoundError:
-    print("find not available", file=sys.stderr)
-
-def cmd_chattr(flag: str, paths):
-  if not have_sp() or not paths:
-    return
-  try:
-    subprocess.run(["chattr", "-V"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
-  except FileNotFoundError:
-    print("chattr not available; lock/unlock skipped", file=sys.stderr); return
-  for rel in paths:
-    target = os.path.normpath(os.path.join(SP, rel))
-    try:
-      if os.path.commonpath([SP]) != os.path.commonpath([SP, target]):
-        continue
-    except Exception:
-      continue
-    try:
-      subprocess.run(["chattr", flag, target], check=False)
-    except Exception:
-      pass
-
-def CLI():
-  if len(sys.argv) < 2:
-    print(HELP); return
-  cmd, *args = sys.argv[1:]
-  if cmd == "ls":
-    if have_sp(): ls_tree(SP)
-    else: return
-  elif cmd == "clear":
-    if len(args) >= 1: clear_subdir(args[0])
-    else: clear_all()
-  elif cmd == "help":
-    print(HELP)
-  elif cmd == "make":
-    cmd_make(args)
-  elif cmd == "write":
-    cmd_write(args)
-  elif cmd == "size":
-    cmd_size()
-  elif cmd == "find":
-    cmd_find(args)
-  elif cmd == "lock":
-    cmd_chattr("+i", args)
-  elif cmd == "unlock":
-    cmd_chattr("-i", args)
-  else:
-    print(HELP)
-
-if __name__ == "__main__":
-  CLI()
diff --git a/shared/authored/setup b/shared/authored/setup
deleted file mode 100644 (file)
index 2284ba2..0000000
+++ /dev/null
@@ -1,130 +0,0 @@
-#!/usr/bin/env bash
-script_afp=$(realpath "${BASH_SOURCE[0]}")
-if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
-  echo "$script_afp:: This script must be sourced, not executed."
-  exit 1
-fi
-
-# without this bash takes non-matching globs literally
-shopt -s nullglob
-
-# does not presume sharing or world permissions
-umask 0077
-
-# --------------------------------------------------------------------------------
-# project definition
-
-# actual absolute director path for this script file
-
-  script_adp(){
-    dirname "$script_afp"
-  }
-
-# assume this script is located  $REPO_HOME/tools_shared/authored and work backwards
-# to get $REPO_HOME, etc.
-
-  REPO_HOME=$(dirname "$(dirname "$(script_adp)")")
-  echo REPO_HOME "$REPO_HOME"
-
-  PROJECT=$(basename "$REPO_HOME")
-  echo PROJECT "$PROJECT"
-
-  # set the prompt decoration to the name of the project
-  PROMPT_DECOR=$PROJECT
-
-  export REPO_HOME PROJECT PROMPT_DECOR
-
-# --------------------------------------------------------------------------------
-# Project wide Tool setup
-#
-
-export VIRTUAL_ENV="$REPO_HOME/shared/third_party/Python"
-export PYTHON_HOME="$VIRTUAL_ENV"
-unset PYTHONHOME
-
-
-# --------------------------------------------------------------------------------
-# PATH
-#   precedence: last defined, first discovered
-  
-  PATH="$REPO_HOME/shared/third_party/RT-project-share/release/bash:$PATH"
-  PATH="$REPO_HOME/shared/third_party/RT-project-share/release/amd64:$PATH"
-  PATH="$REPO_HOME/shared/third_party:$PATH"
-  PATH="$REPO_HOME/shared/authored:$PATH"
-  PATH="$REPO_HOME/shared/made:$PATH"
-
-  # Remove duplicates
-  clean_path() {
-    PATH=$(echo ":$PATH" | awk -v RS=: -v ORS=: '!seen[$0]++' | sed 's/^://; s/:$//')
-  }
-  clean_path
-  export PATH
-
-# --------------------------------------------------------------------------------
-# the following functions are provided for other scripts to use.
-# at the top of files that make use of these functions put the following line:
-#  script_afp=$(realpath "${BASH_SOURCE[0]}")
-#
-
-  ## script's filename
-  script_fn(){
-    basename "$script_afp"
-  }
-
-  ## script's dirpath relative to $REPO_HOME
-  script_fp(){
-    realpath --relative-to="${REPO_HOME}" "$script_afp"
-  }
-
-  ## script's dirpath relative to $REPO_HOME
-  script_dp(){
-    dirname "$(script_fp)"
-  }
-
-  export -f script_adp script_fn script_dp script_fp
-
-#--------------------------------------------------------------------------------
-# used by release scripts
-#
-
-  install_file() {
-    if [ "$#" -lt 3 ]; then
-      echo "env::install_file usage: install_file <source1> <source2> ... <target_dir> <permissions>"
-      return 1
-    fi
-
-    perms="${@: -1}"         # Last argument is permissions
-    target_dp="${@: -2:1}"   # Second-to-last argument is the target directory
-    sources=("${@:1:$#-2}")  # All other arguments are source files
-
-    if [ ! -d "$target_dp" ]; then
-      echo "env::install_file no install done: target directory '$target_dp' does not exist."
-      return 1
-    fi
-
-    for source_fp in "${sources[@]}"; do
-      if [ ! -f "$source_fp" ]; then
-        echo "env::install_file: source file '$source_fp' does not exist."
-        return 1
-      fi
-
-      target_file="$target_dp/$(basename "$source_fp")"
-
-      if ! install -m "$perms" "$source_fp" "$target_file"; then
-        echo "env::install_file: Failed to install $(basename "$source_fp") to $target_dp"
-        return 1
-      else
-        echo "env::install_file: installed $(basename "$source_fp") to $target_dp with permissions $perms"
-      fi
-    done
-  }
-
-  export -f install_file
-
-# --------------------------------------------------------------------------------
-# closing
-#
-  if [[ -z "$ENV" ]]; then
-    export ENV=$(script_fp)
-  fi
-
diff --git a/shared/authored/style/.gitkeep b/shared/authored/style/.gitkeep
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/shared/authored/version b/shared/authored/version
deleted file mode 100755 (executable)
index 905a77b..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-echo "Harmony v2.2 2026-03-06"
-
-
-
diff --git a/shared/tool/scratchpad b/shared/tool/scratchpad
new file mode 100755 (executable)
index 0000000..f14f140
--- /dev/null
@@ -0,0 +1,225 @@
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+import os, sys, shutil, stat, pwd, grp, subprocess
+
+HELP = """usage: scratchpad {ls|clear|help|make|write|size|find|lock|unlock} [ARGS]
+  ls               List scratchpad in an indented tree with perms and owner (quiet if missing).
+  clear            Remove all contents of scratchpad/ except top-level .gitignore.
+  clear NAME       Remove scratchpad/NAME only.
+  make [NAME]      Ensure scratchpad/ exists with .gitignore; with NAME, mkdir scratchpad/NAME.
+  write SRC [DST]  Copy file/dir SRC into scratchpad (to DST if given; parents created).
+  size             Print 'empty' if only .gitignore; else total bytes and item count.
+  find [OPTS...]   Run system 'find' rooted at scratchpad/ with OPTS (omit literal 'scratchpad').
+  lock PATH...     Attempt 'chattr +i' on given paths under scratchpad/ (no state kept).
+  unlock PATH...   Attempt 'chattr -i' on given paths under scratchpad/.
+
+Examples:
+  scratchpad make
+  scratchpad write ~/Downloads/test.tar.gz
+  scratchpad find -type f -mtime +30 -print   # files older than 30 days
+  scratchpad lock some/dir important.txt
+  scratchpad unlock some/dir important.txt
+"""
+
+CWD = os.getcwd()
+SP = os.path.join(CWD, "scratchpad")
+GITIGNORE = os.path.join(SP, ".gitignore")
+
+def have_sp() -> bool:
+  return os.path.isdir(SP)
+
+def ensure_sp():
+  os.makedirs(SP, exist_ok=True)
+  ensure_gitignore()
+
+def ensure_gitignore():
+  os.makedirs(SP, exist_ok=True)
+  if not os.path.isfile(GITIGNORE):
+    with open(GITIGNORE, "w", encoding="utf-8") as f:
+      f.write("*\n!.gitignore\n")
+
+def filemode(mode: int) -> str:
+  try:
+    return stat.filemode(mode)
+  except Exception:
+    return oct(mode & 0o777)
+
+def owner_group(st) -> str:
+  try:
+    return f"{pwd.getpwuid(st.st_uid).pw_name}:{grp.getgrgid(st.st_gid).gr_name}"
+  except Exception:
+    return f"{st.st_uid}:{st.st_gid}"
+
+def rel_depth(base: str, root: str) -> int:
+  rel = os.path.relpath(base, root)
+  return 0 if rel == "." else rel.count(os.sep) + 1
+
+def ls_tree(root: str) -> None:
+  if not have_sp():
+    return
+  print("scratchpad/")
+
+  def walk(path: str, indent: str, is_root: bool) -> None:
+    try:
+      it = list(os.scandir(path))
+    except FileNotFoundError:
+      return
+
+    dirs  = [e for e in it if e.is_dir(follow_symlinks=False)]
+    files = [e for e in it if not e.is_dir(follow_symlinks=False)]
+    dirs.sort(key=lambda e: e.name)
+    files.sort(key=lambda e: e.name)
+
+    if is_root:
+      # 1) root-level hidden files first
+      for f in (e for e in files if e.name.startswith(".")):
+        st = os.lstat(f.path)
+        print(f"{filemode(st.st_mode)}  {owner_group(st)}  {indent}{f.name}")
+      # 2) then directories (and recurse so children sit under the parent)
+      for d in dirs:
+        st = os.lstat(d.path)
+        print(f"{filemode(st.st_mode)}  {owner_group(st)}  {indent}{d.name}/")
+        walk(d.path, indent + '  ', False)
+      # 3) then non-hidden files
+      for f in (e for e in files if not e.name.startswith(".")):
+        st = os.lstat(f.path)
+        print(f"{filemode(st.st_mode)}  {owner_group(st)}  {indent}{f.name}")
+    else:
+      # subdirs: keep previous order (dirs first, then files; dotfiles naturally sort first)
+      for d in dirs:
+        st = os.lstat(d.path)
+        print(f"{filemode(st.st_mode)}  {owner_group(st)}  {indent}{d.name}/")
+        walk(d.path, indent + '  ', False)
+      for f in files:
+        st = os.lstat(f.path)
+        print(f"{filemode(st.st_mode)}  {owner_group(st)}  {indent}{f.name}")
+
+  walk(root, "  ", True)
+
+
+def clear_all() -> None:
+  if not have_sp():
+    return
+  for name in os.listdir(SP):
+    p = os.path.join(SP, name)
+    if name == ".gitignore" and os.path.isfile(p):
+      continue  # preserve only top-level .gitignore
+    if os.path.isdir(p) and not os.path.islink(p):
+      shutil.rmtree(p, ignore_errors=True)
+    else:
+      try: os.unlink(p)
+      except FileNotFoundError: pass
+
+def clear_subdir(sub: str) -> None:
+  if not have_sp():
+    return
+  target = os.path.normpath(os.path.join(SP, sub))
+  try:
+    if os.path.commonpath([SP]) != os.path.commonpath([SP, target]):
+      return
+  except Exception:
+    return
+  if os.path.isdir(target) and not os.path.islink(target):
+    shutil.rmtree(target, ignore_errors=True)
+
+def cmd_make(args):
+  ensure_sp()
+  if args:
+    os.makedirs(os.path.join(SP, args[0]), exist_ok=True)
+
+def cmd_write(args):
+  if len(args) < 1:
+    print(HELP); return
+  if not have_sp():
+    ensure_sp()
+  src = args[0]
+  dst = args[1] if len(args) >= 2 else (os.path.basename(src.rstrip(os.sep)) or "untitled")
+  dst_path = os.path.normpath(os.path.join(SP, dst))
+  try:
+    if os.path.commonpath([SP]) != os.path.commonpath([SP, dst_path]):
+      print("refusing to write outside scratchpad", file=sys.stderr); return
+  except Exception:
+    print("invalid destination", file=sys.stderr); return
+  os.makedirs(os.path.dirname(dst_path), exist_ok=True)
+  if os.path.isdir(src):
+    if os.path.exists(dst_path):
+      shutil.rmtree(dst_path, ignore_errors=True)
+    shutil.copytree(src, dst_path, dirs_exist_ok=False)
+  else:
+    shutil.copy2(src, dst_path)
+
+def cmd_size():
+  if not have_sp():
+    return
+  names = os.listdir(SP)
+  if [n for n in names if n != ".gitignore"] == []:
+    print("empty"); return
+  total = 0; count = 0
+  for base, dirs, files in os.walk(SP):
+    for fn in files:
+      if fn == ".gitignore": 
+        continue
+      p = os.path.join(base, fn)
+      try:
+        total += os.path.getsize(p); count += 1
+      except OSError:
+        pass
+  print(f"bytes={total} items={count}")
+
+def cmd_find(args):
+  if not have_sp():
+    return
+  try:
+    subprocess.run(["find", SP] + args, check=False)
+  except FileNotFoundError:
+    print("find not available", file=sys.stderr)
+
+def cmd_chattr(flag: str, paths):
+  if not have_sp() or not paths:
+    return
+  try:
+    subprocess.run(["chattr", "-V"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
+  except FileNotFoundError:
+    print("chattr not available; lock/unlock skipped", file=sys.stderr); return
+  for rel in paths:
+    target = os.path.normpath(os.path.join(SP, rel))
+    try:
+      if os.path.commonpath([SP]) != os.path.commonpath([SP, target]):
+        continue
+    except Exception:
+      continue
+    try:
+      subprocess.run(["chattr", flag, target], check=False)
+    except Exception:
+      pass
+
+def CLI():
+  if len(sys.argv) < 2:
+    print(HELP); return
+  cmd, *args = sys.argv[1:]
+  if cmd == "ls":
+    if have_sp(): ls_tree(SP)
+    else: return
+  elif cmd == "clear":
+    if len(args) >= 1: clear_subdir(args[0])
+    else: clear_all()
+  elif cmd == "help":
+    print(HELP)
+  elif cmd == "make":
+    cmd_make(args)
+  elif cmd == "write":
+    cmd_write(args)
+  elif cmd == "size":
+    cmd_size()
+  elif cmd == "find":
+    cmd_find(args)
+  elif cmd == "lock":
+    cmd_chattr("+i", args)
+  elif cmd == "unlock":
+    cmd_chattr("-i", args)
+  else:
+    print(HELP)
+
+if __name__ == "__main__":
+  CLI()
diff --git a/shared/tool/setup b/shared/tool/setup
new file mode 100644 (file)
index 0000000..2284ba2
--- /dev/null
@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+script_afp=$(realpath "${BASH_SOURCE[0]}")
+if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
+  echo "$script_afp:: This script must be sourced, not executed."
+  exit 1
+fi
+
+# without this bash takes non-matching globs literally
+shopt -s nullglob
+
+# does not presume sharing or world permissions
+umask 0077
+
+# --------------------------------------------------------------------------------
+# project definition
+
+# actual absolute director path for this script file
+
+  script_adp(){
+    dirname "$script_afp"
+  }
+
+# assume this script is located  $REPO_HOME/tools_shared/authored and work backwards
+# to get $REPO_HOME, etc.
+
+  REPO_HOME=$(dirname "$(dirname "$(script_adp)")")
+  echo REPO_HOME "$REPO_HOME"
+
+  PROJECT=$(basename "$REPO_HOME")
+  echo PROJECT "$PROJECT"
+
+  # set the prompt decoration to the name of the project
+  PROMPT_DECOR=$PROJECT
+
+  export REPO_HOME PROJECT PROMPT_DECOR
+
+# --------------------------------------------------------------------------------
+# Project wide Tool setup
+#
+
+export VIRTUAL_ENV="$REPO_HOME/shared/third_party/Python"
+export PYTHON_HOME="$VIRTUAL_ENV"
+unset PYTHONHOME
+
+
+# --------------------------------------------------------------------------------
+# PATH
+#   precedence: last defined, first discovered
+  
+  PATH="$REPO_HOME/shared/third_party/RT-project-share/release/bash:$PATH"
+  PATH="$REPO_HOME/shared/third_party/RT-project-share/release/amd64:$PATH"
+  PATH="$REPO_HOME/shared/third_party:$PATH"
+  PATH="$REPO_HOME/shared/authored:$PATH"
+  PATH="$REPO_HOME/shared/made:$PATH"
+
+  # Remove duplicates
+  clean_path() {
+    PATH=$(echo ":$PATH" | awk -v RS=: -v ORS=: '!seen[$0]++' | sed 's/^://; s/:$//')
+  }
+  clean_path
+  export PATH
+
+# --------------------------------------------------------------------------------
+# the following functions are provided for other scripts to use.
+# at the top of files that make use of these functions put the following line:
+#  script_afp=$(realpath "${BASH_SOURCE[0]}")
+#
+
+  ## script's filename
+  script_fn(){
+    basename "$script_afp"
+  }
+
+  ## script's dirpath relative to $REPO_HOME
+  script_fp(){
+    realpath --relative-to="${REPO_HOME}" "$script_afp"
+  }
+
+  ## script's dirpath relative to $REPO_HOME
+  script_dp(){
+    dirname "$(script_fp)"
+  }
+
+  export -f script_adp script_fn script_dp script_fp
+
+#--------------------------------------------------------------------------------
+# used by release scripts
+#
+
+  install_file() {
+    if [ "$#" -lt 3 ]; then
+      echo "env::install_file usage: install_file <source1> <source2> ... <target_dir> <permissions>"
+      return 1
+    fi
+
+    perms="${@: -1}"         # Last argument is permissions
+    target_dp="${@: -2:1}"   # Second-to-last argument is the target directory
+    sources=("${@:1:$#-2}")  # All other arguments are source files
+
+    if [ ! -d "$target_dp" ]; then
+      echo "env::install_file no install done: target directory '$target_dp' does not exist."
+      return 1
+    fi
+
+    for source_fp in "${sources[@]}"; do
+      if [ ! -f "$source_fp" ]; then
+        echo "env::install_file: source file '$source_fp' does not exist."
+        return 1
+      fi
+
+      target_file="$target_dp/$(basename "$source_fp")"
+
+      if ! install -m "$perms" "$source_fp" "$target_file"; then
+        echo "env::install_file: Failed to install $(basename "$source_fp") to $target_dp"
+        return 1
+      else
+        echo "env::install_file: installed $(basename "$source_fp") to $target_dp with permissions $perms"
+      fi
+    done
+  }
+
+  export -f install_file
+
+# --------------------------------------------------------------------------------
+# closing
+#
+  if [[ -z "$ENV" ]]; then
+    export ENV=$(script_fp)
+  fi
+
diff --git a/shared/tool/style/.gitkeep b/shared/tool/style/.gitkeep
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/shared/tool/version b/shared/tool/version
new file mode 100755 (executable)
index 0000000..905a77b
--- /dev/null
@@ -0,0 +1,4 @@
+echo "Harmony v2.2 2026-03-06"
+
+
+