Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/workflows/plan-marker-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ jobs:
- name: Run em-recall-sessionstart hook-wrapper integration (cwd + stdin sid binding)
run: bash tests/test-em-recall-sessionstart-hook-binding.sh

- name: Run em-recall-sessionstart hook regression (incl. F1 freshness-probe layout fix)
run: bash tests/test-em-recall-sessionstart.sh

- name: Run preflight-marker reapers (F4 SessionEnd reap + SessionStart 7d orphan sweep)
run: node tests/test-preflight-marker-reapers.mjs

- name: Run no-legacy-plan-marker-writes detector (Rule 13)
run: node tests/test-no-legacy-plan-marker-writes.mjs

Expand Down
7 changes: 6 additions & 1 deletion plugins/claude-code/hooks/em-recall-sessionstart.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ warn_hook_freshness() {
fi
[ -n "$source_repo" ] || return 0

if [ ! -d "$source_repo/hooks" ]; then
# Probe only the repo root, not a fixed subdirectory layout. The old
# `$source_repo/hooks` probe broke when PR #373 moved hooks to
# plugins/claude-code/hooks/ — every SessionStart printed a false
# "source repo unavailable" warning. The per-file loop below already
# classifies missing/relocated sources (missing_source bucket).
if [ ! -d "$source_repo" ]; then
echo "episodic-memory: hook freshness warning: source repo unavailable: $source_repo"
echo "episodic-memory: installed Claude hooks may be stale; re-run install.mjs --install-hooks from the current episodic-memory repo."
return 0
Expand Down
55 changes: 24 additions & 31 deletions plugins/claude-code/hooks/session-handoff-prompt.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# - Resolves repo_root via git common-dir, normalizing linked-worktree → MAIN.
# - Resolves memory_root with BOUNDED candidate set (sanitization variants of
# resolved repo_root ONLY — no ~/.claude/projects/* scan; codex F9).
# - Emits explicit absolute paths for the 8 always-tier discipline files
# - Emits explicit absolute paths for the always-tier discipline files
# (codex F11; replaces "batch-Read the feedback files under MEMORY.md's …
# anchors" prose which loaded 34+ files / ~47k tokens / ~10% of context).
# - Emits mechanical `cd <repo_root> && node <repo_root>/scripts/em-search.mjs …`
Expand All @@ -26,6 +26,11 @@
# r4 …68d0→…87ac HOLD (F12 matrix vary stdin.cwd, F13 fail-safe tightening)
# r5 …5c73→…0355 ACCEPT-with-FU (no new architectural class; #19 trigger)
#
# 2026-06-12 (checkpoint-hygiene C2/F3): backported the installed runtime —
# 6-file always-tier list (2026-05-16 demotions) + condensed Q1/Q2 directive —
# which had drifted ahead of this source; file is now tracked in HOOK_SPECS /
# the freshness manifest so future drift is reported at SessionStart.
#
# Composes with:
# - hooks/lib/repo-root.sh (sourced for resolve_repo_root canonical helper)
# - feedback_project_root_binding_audit.md discipline #20
Expand Down Expand Up @@ -144,7 +149,11 @@ else
fi

# ---------------------------------------------------------------------------
# Always-tier list (8 files, codex r1 F2 added canonical-agent-dispatch).
# Always-tier list (6 files; demotions 2026-05-16):
# - feedback_send_grep_artifact.md → lazy-tier (content trigger: PII /
# sanitization keywords via MEMORY.md Trigger-phrase index)
# - feedback_three_state_review_verdict.md → lazy-tier (loads when
# "verdict" / "ACCEPT" / "HOLD" / "REJECT" / "approving" keywords fire)
# These rules fire on EVERY claim OR on short/no-keyword prompts; lazy-loading
# them would defeat their self-trigger contract (catch-22 from plan v1).
# ---------------------------------------------------------------------------
Expand All @@ -153,8 +162,6 @@ ALWAYS_TIER=(
"feedback_verify_by_artifact.md"
"feedback_self_trigger_artifact_mode.md"
"feedback_per_prompt_rule_preflight.md"
"feedback_send_grep_artifact.md"
"feedback_three_state_review_verdict.md"
"feedback_bp1_step9_filing_trigger.md"
"feedback_canonical_agent_dispatch_trigger.md"
)
Expand Down Expand Up @@ -199,42 +206,28 @@ if [ -f "${HANDOFF}" ]; then
|| stat -c '%y' "${HANDOFF}" 2>/dev/null | cut -c1-16 \
|| echo unknown)"

DIRECTIVE="BLOCKING DIRECTIVE: This session has TWO pending y/n questions you MUST ask in order before responding to the user's prompt.
DIRECTIVE="BLOCKING: Ask both y/n questions before responding. No preamble or tool calls between them.

Phase 1 — Your FIRST message this session must be exactly:
Q1 (verbatim):
Load session_handoff.md from ${MTIME}? (y/n)

Phase 2 — After Phase 1 is answered, your NEXT message must be exactly:
Q2 (verbatim, after Q1 is answered):
Load discipline + toolkit + recent lessons? (y/n)

Q1 answer must be remembered across Phase 2; do NOT drop it.
No preamble, no other content, no tool calls between Phase 1 and Phase 2.

After BOTH answers are collected, process them in this order:
1. If Phase 1 answer = y: Read ${HANDOFF}
2. If Phase 2 answer = y: batch-Read these 8 always-tier discipline files (explicit absolute paths):
${_paths} Then run the lessons search command:
${EM_SEARCH_CMD}
(Lazy-tier discipline files load on demand via Read or trigger-phrase classifier — see MEMORY.md tier-3 trigger-phrase index. This always-tier load replaces the prior 34+ file batch-Read.)
3. Then address the user's original prompt.
Remember Q1's answer across Q2; do NOT drop it.

Do not skip this even if the user's first message asks something else; ask Phase 1 first, then Phase 2, then address their request."
After both answers, process in order:
1. If Q1=y: Read ${HANDOFF}
2. If Q2=y: batch-Read these 6 absolute paths, then run the em-search command:
${_paths} ${EM_SEARCH_CMD}
3. Then address the user's prompt."
else
DIRECTIVE="BLOCKING DIRECTIVE: This session has ONE pending y/n question you MUST ask before responding to the user's prompt.

Your FIRST message this session must be exactly:
DIRECTIVE="BLOCKING: Ask this y/n question (verbatim) before responding, no preamble or tool calls:
Load discipline + toolkit + recent lessons? (y/n)

No preamble, no other content, no tool calls.

After the answer is collected, process it:
1. If answer = y: batch-Read these 8 always-tier discipline files (explicit absolute paths):
${_paths} Then run the lessons search command:
${EM_SEARCH_CMD}
(Lazy-tier discipline files load on demand via Read or trigger-phrase classifier — see MEMORY.md tier-3 trigger-phrase index. This always-tier load replaces the prior 34+ file batch-Read.)
2. Then address the user's original prompt.

Do not skip this even if the user's first message asks something else; ask the y/n question first, then address their request after their answer."
If y: batch-Read these 6 absolute paths, then run the em-search command:
${_paths} ${EM_SEARCH_CMD}
Then address the user's prompt."
fi

jq -n --arg ctx "${DIRECTIVE}" '{
Expand Down
41 changes: 39 additions & 2 deletions scripts/classifier-marker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,12 @@ function vacuumMarkers(projectRootCanon, maxAgeDays) {
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000
let removed = 0, scanned = 0
for (const entry of fs.readdirSync(classifyDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
// Checkpoint-hygiene F5: vacuum also reaps aged one-shot *.cmd staging
// files (the deny-hint flow stages them here; consume-unlink in --write
// handles the happy path, vacuum is the backstop for aborted flows).
// Verdict *.json cache semantics unchanged.
const reapable = entry.name.endsWith('.json') || entry.name.endsWith('.cmd')
if (!entry.isFile() || !reapable) continue
scanned++
const p = path.join(classifyDir, entry.name)
try {
Expand Down Expand Up @@ -676,13 +681,45 @@ function mainWrite(argv) {
classified_by: 'agent_self'
}
const written = writeMarker(classifyDir, sha, payload)

// Checkpoint-hygiene F5: consume the one-shot --command-file staging file
// once a verdict is on file. Keyed on the common exit-0 epilogue so EVERY
// success status ('written' AND 'noop_same_tuple') consumes — a same-tuple
// re-write must not strand its staging file. Unlink ONLY when the
// canonicalized file sits inside the validated classifyDir (single
// canonical pass via realpath, never raw-string prefix on the input) and
// lstat says regular file (symlinks are never followed or removed).
// Staging files outside classifyDir belong to the agent and stay put.
const cmdFileArg = flag(argv, '--command-file')
let commandFileConsumed = false
if (cmdFileArg !== undefined) {
try {
// lstat the ARGV path BEFORE any realpath: a symlink staging file is
// never consumed, even when its canonical target lands inside
// classifyDir. Codex code-review R1 P1 (episode 20260612-101323-…-a15e)
// reproduced the realpath-first ordering deleting an in-dir symlink's
// in-dir TARGET — a wrong-family deletion inside the correct root.
const argvPath = path.resolve(cmdFileArg)
const argvSt = fs.lstatSync(argvPath)
if (argvSt.isFile()) {
const realCf = realpathOrSame(argvPath)
const st = fs.lstatSync(realCf)
if (st.isFile() && isUnder(realCf, classifyDir) && realCf !== written.file) {
fs.unlinkSync(realCf)
commandFileConsumed = true
}
}
} catch { /* best-effort: a vanished staging file never fails the write */ }
}

emit({
status: written.status,
file: written.file,
label,
cache_key: sha,
session_id: sessionId,
project_root_used: projectRoot
project_root_used: projectRoot,
command_file_consumed: commandFileConsumed
})
process.exit(0)
}
Expand Down
40 changes: 40 additions & 0 deletions scripts/em-recall.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
bothMarkerPaths,
namespacedMarkerBasenameForSession,
CHECKPOINT_QUARTET,
preflightMarkerSuffixedBasenameMatches,
lastUserPromptBasenameMatches,
} from './lib/marker-paths.mjs'
import {
_maxMtimeAcrossRootsStrict,
Expand Down Expand Up @@ -748,6 +750,44 @@ if (sessionStartFlag) {
}
}

// ---------------------------------------------------------------------
// Preflight-family orphan sweep (checkpoint-hygiene F4, closes the #283
// SessionStart half). `.preflight-done.<sid>` and
// `.last-user-prompt.<sid>.json` are written by preflight-prompt-helper.sh
// every session; SessionEnd reaps the own-session pair, this sweep reaps
// crashed-session orphans.
//
// Containment + safety:
// - readdir of the PRIMARY marker dir only (families never had legacy
// .claude/ forms); basenames carry no path separators.
// - suffixED-only matchers — the legacy suffix-less `.preflight-done`
// is burn-in/F7 scope and is preserved.
// - 7-day mtime guard: a live concurrent session's markers are days,
// not weeks, old. Worst case (week-idle still-open session) the
// preflight gate re-prompts once — fail-closed in the safe direction.
// - lstat per entry; symlinks never followed or unlinked (mirrors the
// classifier-marker vacuum contract).
// ---------------------------------------------------------------------
const PREFLIGHT_ORPHAN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
const sweepCutoff = Date.now() - PREFLIGHT_ORPHAN_MAX_AGE_MS
const primaryDir = path.join(REPO_ROOT, PRIMARY_MARKER_DIR)
let sweepEntries = []
try { sweepEntries = fs.readdirSync(primaryDir) } catch { sweepEntries = [] }
for (const name of sweepEntries) {
if (!preflightMarkerSuffixedBasenameMatches(name) && !lastUserPromptBasenameMatches(name)) continue
const p = path.join(primaryDir, name)
try {
const st = fs.lstatSync(p)
if (st.isSymbolicLink()) continue
if (!st.isFile()) continue
if (st.mtimeMs < sweepCutoff) fs.unlinkSync(p)
} catch (e) {
if (e.code !== 'ENOENT') {
process.stderr.write(`em-recall: preflight-orphan-sweep skipped ${p}: ${e.code || e.message}\n`)
}
}
}

} catch {
// Best-effort: a failure here leaves the legacy-plan-marker sweep
// inactive; SessionEnd hook still owns own-session cleanup.
Expand Down
17 changes: 17 additions & 0 deletions scripts/em-session-end-prompt.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
namespacedMarkerBasenameForSession,
CHECKPOINT_QUARTET,
PLAN_APPROVED_LEGACY_BASENAME,
preflightMarkerBasenameForSession,
lastUserPromptBasenameForSession,
} from './lib/marker-paths.mjs'
import { validateSessionId } from './lib/session-id.mjs'

Expand Down Expand Up @@ -165,6 +167,21 @@ for (const marker of ALL_MIGRATED_MARKERS) {
}
}

// Checkpoint-hygiene F4 (closes the #283 SessionEnd half): own-session reap
// of the per-session preflight families written by preflight-prompt-helper.sh
// every session. Primary dir ONLY — these families never had legacy .claude/
// forms. Own-session only (mirrors the quartet's cross-session contract);
// crashed-session orphans are reaped by the em-recall SessionStart sweep
// (7-day mtime guard). Invalid/missing sid → skip; the orphan sweep handles it.
if (validateSessionId(sessionEndSid)) {
for (const basename of [
preflightMarkerBasenameForSession(sessionEndSid),
lastUserPromptBasenameForSession(sessionEndSid),
]) {
try { fs.unlinkSync(primaryMarkerPath(repoRoot, basename)) } catch {}
}
}

const patterns = loadPatternsIndex(repoRoot)

const knownPatterns = patterns.map(p => ({
Expand Down
10 changes: 10 additions & 0 deletions scripts/lib/install-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ export const HOOK_SPECS = [
file: 'preflight-prompt-helper.sh',
event: 'UserPromptSubmit',
timeout: 5
},
// session-handoff-prompt.sh: SessionStart two-phase handoff/discipline
// directive (rank-10 slice). Was installed + registered manually but never
// tracked here, so the installed copy drifted ahead of the repo source
// unreported (checkpoint-hygiene F3, diagnosis 20260611-234742). Timeout 5
// matches the pre-existing manual registration in settings.json.
{
file: 'session-handoff-prompt.sh',
event: 'SessionStart',
timeout: 5
}
]

Expand Down
56 changes: 56 additions & 0 deletions scripts/lib/marker-paths.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,62 @@ export function preflightMarkerBasenameForSession(sid) {
return PREFLIGHT_MARKER_BASENAME_TEMPLATE.replace('{sid}', sid)
}

/**
* Suffixed-ONLY preflight-marker match (checkpoint-hygiene F4, closes part
* of #283). Accepts `.preflight-done.<sid>`; rejects the legacy suffix-less
* `.preflight-done` — its lifecycle belongs to the burn-in cutover (F7),
* not the per-session orphan sweep. Reuses PREFLIGHT_MARKER_BASENAME_RE
* (suffix capture group must be non-null) so the two matchers cannot drift.
*
* @param {string} basename
* @returns {boolean}
*/
export function preflightMarkerSuffixedBasenameMatches(basename) {
if (typeof basename !== 'string') return false
const m = PREFLIGHT_MARKER_BASENAME_RE.exec(basename)
return !!(m && m[1])
}

// ---------------------------------------------------------------------------
// Checkpoint-hygiene F4 — per-session .last-user-prompt sidecar contract.
//
// Written by preflight-prompt-helper.sh (UserPromptSubmit) as
// `.last-user-prompt.<sid>.json`; consumed by preflight-gate.sh for true
// prompt binding (#238 PR1 FU-C2). Suffix-MANDATORY by construction — there
// was never a suffix-less legacy form, so the sweep matcher has no
// burn-in carve-out.
// ---------------------------------------------------------------------------

export const LAST_USER_PROMPT_BASENAME_TEMPLATE = '.last-user-prompt.{sid}.json'
export const LAST_USER_PROMPT_BASENAME_RE = /^\.last-user-prompt\.[A-Za-z0-9_-]{1,128}\.json$/

/**
* Strict match for per-session last-user-prompt sidecar basenames. Accepts ONLY:
* .last-user-prompt.<sid>.json (sid matches char-class + length)
* Rejects:
* .last-user-prompt.json (no sid)
* .last-user-prompt.<sid> (missing .json)
* .last-user-prompt.a/b.json (slash in sid)
* .last-user-prompt.<129-char>.json (oversize sid)
*
* @param {string} basename
* @returns {boolean}
*/
export function lastUserPromptBasenameMatches(basename) {
return typeof basename === 'string' && LAST_USER_PROMPT_BASENAME_RE.test(basename)
}

/**
* Compose the per-session last-user-prompt basename for a given session id.
* Caller MUST validateSessionId(sid) first; this helper does not re-validate.
*
* @param {string} sid — valid session-id
* @returns {string} '.last-user-prompt.<sid>.json'
*/
export function lastUserPromptBasenameForSession(sid) {
return LAST_USER_PROMPT_BASENAME_TEMPLATE.replace('{sid}', sid)
}

// ---------------------------------------------------------------------------
// Rank-2 (PR for checkpoint-quartet) — generic per-session marker contract.
//
Expand Down
Loading
Loading