Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .opencode/plugins/fm-primary-watch-arm.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ function shouldArm(paths) {
}
}

function parentPid(pid) {
const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" });
if (result.status !== 0) return "";
return result.stdout.trim();
}

async function sessionOwnsLock(paths) {
let lockPid = "";
try {
Expand All @@ -122,9 +128,7 @@ async function sessionOwnsLock(paths) {
let pid = String(process.pid);
for (let i = 0; i < 8; i += 1) {
if (pid === lockPid) return true;
const result = await runProcess("ps", ["-o", "ppid=", "-p", pid]);
if (result.code !== 0) return false;
pid = result.stdout.trim();
pid = parentPid(pid);
if (!pid || pid === "1") return false;
}
return false;
Expand Down Expand Up @@ -302,7 +306,7 @@ function spawnArm(paths, sessionID, client, predecessorArmPid = "") {
FM_CONFIG_OVERRIDE: paths.config,
FM_WATCH_PREDECESSOR_ARM_PID: predecessorArmPid,
};
const armChild = spawn("bash", ["-lc", 'config_dir="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"; [ -f "$config_dir/x-mode.env" ] && . "$config_dir/x-mode.env"; exec "$FM_ROOT_OVERRIDE/bin/fm-watch-arm.sh" --restart'], {
const armChild = spawn("bash", ["-c", 'config_dir="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"; [ -f "$config_dir/x-mode.env" ] && . "$config_dir/x-mode.env"; exec "$FM_ROOT_OVERRIDE/bin/fm-watch-arm.sh" --restart'], {
cwd: paths.root,
env,
stdio: ["ignore", "pipe", "pipe"],
Expand Down
2 changes: 1 addition & 1 deletion .pi/extensions/fm-primary-pi-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ export default function (pi: ExtensionAPI) {
FM_WATCH_ARM_SCRIPT: armScript,
FM_WATCH_PREDECESSOR_ARM_PID: predecessorArmPid,
};
const armChild = spawn("bash", ["-lc", "config_dir=\"${FM_CONFIG_OVERRIDE:-$FM_HOME/config}\"; [ -f \"$config_dir/x-mode.env\" ] && . \"$config_dir/x-mode.env\"; exec \"$FM_WATCH_ARM_SCRIPT\" --restart"], {
const armChild = spawn("bash", ["-c", "config_dir=\"${FM_CONFIG_OVERRIDE:-$FM_HOME/config}\"; [ -f \"$config_dir/x-mode.env\" ] && . \"$config_dir/x-mode.env\"; exec \"$FM_WATCH_ARM_SCRIPT\" --restart"], {
cwd: fmRoot,
env,
stdio: ["ignore", "pipe", "pipe"],
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole
backlog.md task queue, dependencies, history
captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update
captain-shared.md main-authoritative shared captain preferences propagated read-only to secondmate homes; LOCAL, gitignored, owned by secondmate-provisioning
memory/ fleet-local operational knowledge as one atomic note per claim, plus an optional standing core, the regenerable catalog, and the never-injected drop tray; LOCAL, gitignored; curated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; bin/fm-memory-compile.sh owns the note format and what session start injects, and bin/fm-memory-migrate.sh owns creating this layout from a home's legacy learnings.md
memory/ fleet-local operational knowledge as one atomic note per claim, plus an optional standing core, the dated operating picture now.md, the regenerable catalog, and the never-injected drop tray; LOCAL, gitignored; curated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; bin/fm-memory-compile.sh owns the note format and what session start injects, and bin/fm-memory-migrate.sh owns creating this layout from a home's legacy learnings.md
projects.md thin fleet navigation registry recording each project's standing delivery posture; firstmate-private, parsed for mechanical sync and seeding by fm-project-mode.sh (section 6)
secondmates.md local and remote secondmate routing table; firstmate-private, maintained by the secondmate seed helpers (section 6)
<id>/brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate
Expand Down Expand Up @@ -171,7 +171,7 @@ When that section reports its checks still in progress it names exactly what is
6. **Network checks** - after the fleet-state digest, the deferred stage's result, or an explicit statement of what it has not confirmed yet.
A read-only session runs no network checks at all and says so.
7. **Context digest and next step** - last of the bulk sections, the full contents of `data/projects.md`, `data/secondmates.md`, and `data/captain-shared.md`, plus this session's curated memory, each clearly delimited, followed by the closing reminder.
Curated memory is compiled and capped by `bin/fm-memory-compile.sh`, never dumped: it carries a standing core, a catalog of every note that exists, and the notes whose triggers matched live fleet work.
Curated memory is compiled and capped by `bin/fm-memory-compile.sh`, never dumped: it carries a standing core, the dated operating picture when `data/memory/now.md` is dated today, a catalog of every note that exists, and the notes whose triggers matched live fleet work.
Reading one further note by its catalog path when its title matches what the turn needs is expected and is not a re-read; a home with no `data/memory/` layout, or a session whose compile failed, falls back to the whole-file print of `data/captain.md` and `data/learnings.md`.
A file that does not exist prints an explicit `ABSENT` marker, never confused with an empty-but-present file: absence is meaningful (`captain.md` absent means use the firstmate repo's built-in defaults, `projects.md` absent means rebuild it from the clones under `projects/`, etc.).
The closing reminder points back to the emitted supervision block and preserves only the lock, afk, Relay, and read-once reminders.
Expand Down
2 changes: 1 addition & 1 deletion bin/fm-landing-remote.sh
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ refresh_no_mistakes() {
echo "warning: no-mistakes is not on PATH; after origin points at the landing remote, run: no-mistakes init" >&2
return 0
fi
( cd "$REPO" && no-mistakes init ) || return 1
( cd "$REPO" && no-mistakes --yes init ) || return 1
}

cmd_status() {
Expand Down
219 changes: 193 additions & 26 deletions bin/fm-memory-compile.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@
# constitution by default and is used as the core when core.md
# is absent, so a home that never authors core.md loses nothing.
# core.md takes precedence the moment it exists.
# now.md the dated operating picture. Carries this-shift pins and
# ceilings with a front matter date. Injected only when dated
# today; a stale file is dropped and reported, while absence is
# silent. It is read from data/memory/now.md in the home rather
# than from the generation HEAD names, because it is perishable
# shift state rather than versioned knowledge: a published
# generation must not freeze it, and publishing must not discard
# it. --memory-dir and --gen move it to the named directory
# along with everything else, so verifying one generation stays
# a function of that generation alone.
# notes/*.md atomic notes, one claim each.
# catalog.md the regenerable index, one line per note: claim title, file
# name under notes/, first triggers, and updated date.
Expand All @@ -45,14 +55,30 @@
# Matching is case-insensitive and bounded by non-alphanumeric characters at
# both ends, so `lint` matches `commands.lint` but not `linting`.
#
# OPERATING PICTURE FORMAT. now.md opens with a YAML-style front matter block
# delimited by a bare `---` on line 1 and the next bare `---`:
# date: ISO date (YYYY-MM-DD), matched against today's date
# updated: the same ISO date under the note format's key name, read only as a
# fallback when no `date:` key is present, so `date:` always wins
# Today is the local host date (`date +%Y-%m-%d`), one clock rather than two, so
# the window a file stays valid for is never wider than a day.
# FM_MEMORY_TODAY_OVERRIDE replaces that date and exists so a test can pin it.
# An undated or stale now.md is never injected.
#
# SELECTION AND CAP. config/startup-memory-budget owns the cap and
# bin/fm-startup-memory-budget-lib.sh owns the ceil(UTF-8 bytes / 3) estimate.
# This script accounts memory CONTENT bytes against that cap, exactly as
# bin/fm-startup-memory-budget.sh report does, and excludes its own framing
# lines. Precedence under pressure is core, then catalog, then hot notes:
# lines. Precedence under pressure is core, then operating picture, then catalog,
# then hot notes:
# - core is never dropped and never truncated;
# - core alone over budget prints core plus a loud MEMORY_BUDGET_WARNING and
# no catalog and no notes;
# no operating picture, no catalog, and no notes;
# - the operating picture (now.md) is kept ahead of the catalog when dated
# today, because a stale ceiling is the failure this tier exists to prevent;
# - an operating picture that does not fit beside the core is dropped with a
# loud MEMORY_BUDGET_WARNING and the fill continues, so the newest tier can
# never blank the catalog the way one oversized file otherwise would;
# - the catalog is kept ahead of every hot note, because it is the thing that
# tells the next turn a note exists at all;
# - hot notes are added newest-updated first, and one that does not fit is
Expand Down Expand Up @@ -502,8 +528,71 @@ tokens_of_file() {
CORE_PATH=
CORE_LABEL=
CORE_TOKENS=0
NOW_PATH=
NOW_LABEL=
NOW_TOKENS=0
NOW_VALID=0
NOW_DATE=
NOTICES=()

parse_now_date() {
local path=$1
[ -f "$path" ] && [ ! -L "$path" ] || return 1
awk '
function clean(s) {
gsub(/\t/, " ", s)
sub(/^[[:space:]]+/, "", s)
sub(/[[:space:]]+$/, "", s)
gsub(/^["\047]+|["\047]+$/, "", s)
return s
}
BEGIN { fm = 0; date = ""; fallback = ""; have_date = 0 }
FNR == 1 {
if ($0 ~ /^---[[:space:]]*$/) { fm = 1; next }
exit
}
fm && $0 ~ /^---[[:space:]]*$/ { exit }
fm {
if (match($0, /^[A-Za-z_][A-Za-z0-9_-]*:[[:space:]]*/)) {
key = tolower(substr($0, 1, index($0, ":") - 1))
val = clean(substr($0, RLENGTH + 1))
if (key == "date") {
date = val
have_date = 1
} else if (key == "updated" && !have_date) {
fallback = val
}
}
}
END {
print have_date ? date : fallback
}
' "$path"
}

# Today is settled once, from the local host clock the captain reads, so the
# validity window is exactly one day everywhere rather than widening wherever
# local time and UTC disagree.
TODAY_DATE="${FM_MEMORY_TODAY_OVERRIDE:-}"
if [ -z "$TODAY_DATE" ]; then
TODAY_DATE=$(date +%Y-%m-%d 2>/dev/null || true)
fi

is_today_date() {
local d=$1
[ -n "$d" ] || return 1
[ -n "$TODAY_DATE" ] || return 1
[ "$d" = "$TODAY_DATE" ]
}

get_today_display() {
if [ -n "$TODAY_DATE" ]; then
printf '%s' "$TODAY_DATE"
return
fi
printf 'today'
}

if [ "$NOTES_DIR_SYMLINK" -eq 1 ]; then
NOTICES+=("MEMORY_NOTICE: $REL_LABEL/notes/ is a symlink, so no note was read through it. Any notes under it are NOT in this bundle and are reported here as absent. Replace the symlink with a real directory.")
fi
Expand Down Expand Up @@ -534,6 +623,39 @@ else
NOTICES+=("MEMORY_NOTICE: no core memory - both $REL_LABEL/core.md and data/captain.md are ABSENT, so this home is running on the firstmate repo built-in defaults.")
fi

# The operating picture follows the directory the caller asked for, and only
# falls back to the home-level data/memory/now.md when the caller asked for
# nothing. A named directory therefore compiles from itself alone, so
# bin/fm-memory-verify.sh's budget gate on a proposed generation stays a
# function of that generation and never of today's perishable shift file.
NOW_DIR="$DATA/memory"
NOW_REL="data/memory"
NOW_DIR_OK=1
if [ -n "$EXPLICIT_MEMORY_DIR" ]; then
NOW_DIR="$MEMORY"
NOW_REL="$REL_LABEL"
NOW_DIR_OK=$MEMORY_DIR_OK
elif [ -L "$DATA/memory" ] || [ ! -d "$DATA/memory" ]; then
NOW_DIR_OK=0
fi

if [ "$NOW_DIR_OK" -eq 1 ] && [ -L "$NOW_DIR/now.md" ]; then
NOTICES+=("MEMORY_NOTICE: $NOW_REL/now.md is a symlink, so nothing was read through it. Replace the symlink with a real file, or remove it.")
elif [ "$NOW_DIR_OK" -eq 1 ] && [ -f "$NOW_DIR/now.md" ]; then
NOW_DATE=$(parse_now_date "$NOW_DIR/now.md")
if is_today_date "$NOW_DATE"; then
NOW_VALID=1
NOW_PATH="$NOW_DIR/now.md"
NOW_LABEL="$NOW_REL/now.md"
NOW_TOKENS=$(tokens_of_file "$NOW_PATH")
elif [ -n "$NOW_DATE" ]; then
TODAY_DISP=$(get_today_display)
NOTICES+=("MEMORY_NOTICE: $NOW_REL/now.md is dated $NOW_DATE (not today, $TODAY_DISP) and is NOT injected. Update it with this shift's pins and ceilings, or remove it.")
else
NOTICES+=("MEMORY_NOTICE: $NOW_REL/now.md has no date in front matter and is NOT injected. Add a date (date: YYYY-MM-DD), or remove it.")
fi
fi

render_catalog > "$TMP/catalog"
CATALOG_TOKENS=$(tokens_of_file "$TMP/catalog")

Expand All @@ -549,17 +671,33 @@ if [ -f "$DATA/learnings.md" ] && [ ! -L "$DATA/learnings.md" ]; then
NOTICES+=("MEMORY_NOTICE: data/learnings.md is still present (${LEARNINGS_TOKENS} estimated tokens) and is NOT injected. Migrate it into notes with bin/fm-memory-migrate.sh, or read it directly when a turn needs it.")
fi

# Precedence under pressure: core, then catalog, then hot notes.
# Precedence under pressure: core, then operating picture, then catalog, then hot notes.
TOTAL=$CORE_TOKENS
CORE_OVER=0
NOW_KEPT=0
NOW_OVER=0
CATALOG_KEPT=1

if ! fm_startup_memory_decimal_le "$TOTAL" "$BUDGET"; then
CORE_OVER=1
CATALOG_KEPT=0
elif ! fm_startup_memory_decimal_le "$((TOTAL + CATALOG_TOKENS))" "$BUDGET"; then
CATALOG_KEPT=0
else
TOTAL=$((TOTAL + CATALOG_TOKENS))
if [ "$NOW_VALID" -eq 1 ]; then
if fm_startup_memory_decimal_le "$((TOTAL + NOW_TOKENS))" "$BUDGET"; then
NOW_KEPT=1
TOTAL=$((TOTAL + NOW_TOKENS))
else
# One oversized operating picture is dropped on its own and the fill goes
# on, so it can never take the catalog - the only thing that tells the
# next turn a note exists - down with it.
NOW_OVER=1
fi
fi
if ! fm_startup_memory_decimal_le "$((TOTAL + CATALOG_TOKENS))" "$BUDGET"; then
CATALOG_KEPT=0
else
TOTAL=$((TOTAL + CATALOG_TOKENS))
fi
fi

HOT_KEPT=0
Expand Down Expand Up @@ -611,24 +749,48 @@ else
fi

if [ "$CORE_OVER" -eq 1 ]; then
printf '\nMEMORY_BUDGET_WARNING: the core alone is %s estimated tokens against a %s budget. It was printed in full and NOTHING else was: no catalog, no notes. Trim the core (%s/core.md, or data/captain.md when no core.md exists) or raise config/startup-memory-budget.\n' \
"$CORE_TOKENS" "$BUDGET" "$REL_LABEL"
elif [ "$CATALOG_KEPT" -eq 0 ]; then
printf '\nMEMORY_BUDGET_WARNING: the core plus catalog is %s estimated tokens against a %s budget, so the catalog and every note were dropped. Trim the core or raise config/startup-memory-budget; until then this session cannot see what notes exist.\n' \
"$((CORE_TOKENS + CATALOG_TOKENS))" "$BUDGET"
DROPPED_LIST='no catalog, no notes'
if [ "$NOW_VALID" -eq 1 ]; then
DROPPED_LIST='no operating picture, no catalog, no notes'
fi
printf '\nMEMORY_BUDGET_WARNING: the core alone is %s estimated tokens against a %s budget. It was printed in full and NOTHING else was: %s. Trim the core (%s/core.md, or data/captain.md when no core.md exists) or raise config/startup-memory-budget.\n' \
"$CORE_TOKENS" "$BUDGET" "$DROPPED_LIST" "$REL_LABEL"
else
printf '\ncatalog (compiled from %s/notes/)\n%s\n' "$REL_LABEL" "$RULE"
cat "$TMP/catalog"

while IFS= read -r base; do
[ -n "$base" ] || continue
printf '\nhot note: notes/%s\n%s\n' "$base" "$RULE"
cat "$NOTES_DIR/$base"
done < "$TMP/selected"

if [ "$HOT_DROPPED" -gt 0 ]; then
printf '\nMEMORY_BUDGET_NOTICE: %s trigger-matched note(s) did not fit the budget and were not injected. Every one of them is still listed in the catalog above; read it by path when its title matches.\n' \
"$HOT_DROPPED"
if [ "$NOW_KEPT" -eq 1 ]; then
printf '\noperating picture: %s\n%s\n' "$NOW_LABEL" "$RULE"
cat "$NOW_PATH"
elif [ "$NOW_OVER" -eq 1 ]; then
NOW_OVER_REMAINDER=''
if [ "$CATALOG_KEPT" -eq 1 ]; then
NOW_OVER_REMAINDER=' The catalog and notes below were filled from what remains.'
fi
printf '\nMEMORY_BUDGET_WARNING: the core plus operating picture is %s estimated tokens against a %s budget, so the operating picture was dropped and this session is running without today'"'"'s pins and ceilings.%s Trim %s or raise config/startup-memory-budget.\n' \
"$((CORE_TOKENS + NOW_TOKENS))" "$BUDGET" "$NOW_OVER_REMAINDER" "$NOW_LABEL"
fi

if [ "$CATALOG_KEPT" -eq 0 ]; then
KEPT_LIST='the core plus catalog'
TRIM_LIST='the core'
if [ "$NOW_KEPT" -eq 1 ]; then
KEPT_LIST='the core plus operating picture plus catalog'
TRIM_LIST='the core or operating picture'
fi
printf '\nMEMORY_BUDGET_WARNING: %s is %s estimated tokens against a %s budget, so the catalog and every note were dropped. Trim %s or raise config/startup-memory-budget; until then this session cannot see what notes exist.\n' \
"$KEPT_LIST" "$((TOTAL + CATALOG_TOKENS))" "$BUDGET" "$TRIM_LIST"
else
printf '\ncatalog (compiled from %s/notes/)\n%s\n' "$REL_LABEL" "$RULE"
cat "$TMP/catalog"

while IFS= read -r base; do
[ -n "$base" ] || continue
printf '\nhot note: notes/%s\n%s\n' "$base" "$RULE"
cat "$NOTES_DIR/$base"
done < "$TMP/selected"

if [ "$HOT_DROPPED" -gt 0 ]; then
printf '\nMEMORY_BUDGET_NOTICE: %s trigger-matched note(s) did not fit the budget and were not injected. Every one of them is still listed in the catalog above; read it by path when its title matches.\n' \
"$HOT_DROPPED"
fi
fi
fi

Expand All @@ -644,12 +806,17 @@ done
STATUS=within-budget
if [ "$CORE_OVER" -eq 1 ]; then
STATUS=over-budget
elif [ "$CATALOG_KEPT" -eq 0 ] || [ "$HOT_DROPPED" -gt 0 ]; then
elif [ "$NOW_OVER" -eq 1 ] || [ "$CATALOG_KEPT" -eq 0 ] || [ "$HOT_DROPPED" -gt 0 ]; then
STATUS=capped
fi

printf '\nMEMORY_ACCOUNTING: budget=%s core=%s catalog=%s hot_notes=%s hot_notes_tokens=%s notes_total=%s hot_dropped=%s injected_total=%s status=%s\n' \
"$BUDGET" "$CORE_TOKENS" \
NOW_ACC=""
if [ "$NOW_VALID" -eq 1 ]; then
NOW_ACC=" now=$([ "$NOW_KEPT" -eq 1 ] && printf '%s' "$NOW_TOKENS" || printf '0')"
fi

printf '\nMEMORY_ACCOUNTING: budget=%s core=%s%s catalog=%s hot_notes=%s hot_notes_tokens=%s notes_total=%s hot_dropped=%s injected_total=%s status=%s\n' \
"$BUDGET" "$CORE_TOKENS" "$NOW_ACC" \
"$([ "$CATALOG_KEPT" -eq 1 ] && printf '%s' "$CATALOG_TOKENS" || printf '0')" \
"$HOT_KEPT" "$HOT_TOKENS" \
"$(wc -l < "$TMP/inventory" | tr -d ' ')" \
Expand Down
Loading
Loading