therookie 0.4.28 → 0.4.30
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/package.json +1 -1
- package/src/commands/doctor.js +8 -1
- package/src/commands/save.js +7 -0
- package/src/lib/orphan-extract-core.js +8 -1
- package/src/lib/session-write-marker.js +57 -0
- package/templates/hook/flush-session.sh +8 -0
- package/templates/hook/lib/orphan-recovery.sh +42 -0
- package/templates/skill/SKILL.md +4 -0
- package/templates/skill/references/save.md +5 -1
- package/version.json +2 -2
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -424,8 +424,12 @@ function printOrphanPending() {
|
|
|
424
424
|
}
|
|
425
425
|
const expiredCount =
|
|
426
426
|
countDirEntries(join(root, 'expired')) + countDirEntries(join(root, 'codex', 'expired'))
|
|
427
|
+
// spec 2026-09-08 §2.4 — 사유별로 나눠 센다. 저장 0건이 쌓이는 것과 비정상 종료가 쌓이는 것은
|
|
428
|
+
// 원인도 조치도 다르다. 한 숫자로 뭉치면 진짜 복구 대기가 묻힌다.
|
|
429
|
+
const allMarkers = [...claude.markers, ...codex.markers]
|
|
430
|
+
const noWrites = allMarkers.filter((m) => m?.reason === 'no_writes').length
|
|
427
431
|
const s = summarizeOrphanBacklog({
|
|
428
|
-
pendingMarkers:
|
|
432
|
+
pendingMarkers: allMarkers,
|
|
429
433
|
expiredCount,
|
|
430
434
|
nowMs: Date.now(),
|
|
431
435
|
})
|
|
@@ -444,6 +448,9 @@ function printOrphanPending() {
|
|
|
444
448
|
)
|
|
445
449
|
}
|
|
446
450
|
}
|
|
451
|
+
if (noWrites > 0) {
|
|
452
|
+
console.log(`— 저장 0건 세션: ${noWrites}건 대기 — 그 세션의 결정·사실이 아직 어디에도 없습니다. rookie orphan-extract 로 확인하세요.`)
|
|
453
|
+
}
|
|
447
454
|
if (s.expiredCount > 0) {
|
|
448
455
|
console.log(`— orphan expired(손실): 누적 ${s.expiredCount}건 — TTL 초과로 tail 미추출 상태로 만료된 세션`)
|
|
449
456
|
}
|
package/src/commands/save.js
CHANGED
|
@@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
13
13
|
import { readFileSync } from 'node:fs'
|
|
14
14
|
import { loadConfig, getHostname } from '../config.js'
|
|
15
15
|
import { apiPost } from '../api.js'
|
|
16
|
+
import { recordSessionWrite } from '../lib/session-write-marker.js'
|
|
16
17
|
|
|
17
18
|
// 저장된 층을 **빠짐없이** 화면에 센다. 여기 없는 층은 서버가 저장해도 0건처럼 보인다 —
|
|
18
19
|
// 2026-09-02 저장에서 durable fact 3건이 그렇게 안 보였고, metric·recall 로 따로 확인해야
|
|
@@ -116,6 +117,12 @@ export async function runSave(flags) {
|
|
|
116
117
|
throw err
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
// spec 2026-09-08 §2.4 — 이 세션에 저장이 있었다는 로컬 마커. Stop hook 이 없으면 미추출 큐에 넣는다.
|
|
121
|
+
// --session 미전달이면 아무것도 안 한다(기존 호출자 호환).
|
|
122
|
+
if (res.all_committed !== false) {
|
|
123
|
+
recordSessionWrite(typeof flags.session === 'string' ? flags.session : '')
|
|
124
|
+
}
|
|
125
|
+
|
|
119
126
|
if (flags.json) {
|
|
120
127
|
// 서버 raw 응답 그대로 — 재가공 없음. SKILL 은 summary.working_cards.ids[0]·superseded_count 를 읽는다.
|
|
121
128
|
console.log(JSON.stringify(res))
|
|
@@ -32,6 +32,9 @@ export function listPendingExtractions({ orphanRoot }) {
|
|
|
32
32
|
sid,
|
|
33
33
|
runtime,
|
|
34
34
|
transcriptPath: m.transcript_path || '',
|
|
35
|
+
// spec 2026-09-08 §2.4 — 'no_writes'(저장 0건) 와 비정상 종료를 구분해 표시한다.
|
|
36
|
+
// 섞이면 진짜 복구 대기가 묻힌다.
|
|
37
|
+
reason: m.reason || 'crash',
|
|
35
38
|
recoveredAt: m.recovered_at || null,
|
|
36
39
|
// codex marker 는 codex 서브루트에 산다 — marker 명령이 이 루트를 겨냥해야
|
|
37
40
|
// 큐에서 실제로 빠진다(코드검토 P2). orphan-recovery.sh 의 ROOKIE_ORPHAN_DIR 규약.
|
|
@@ -101,7 +104,11 @@ export function buildDraft(messages, { userMax = 300, assistantMax = 400, pathCa
|
|
|
101
104
|
export function renderDraft(entry, draft) {
|
|
102
105
|
const lines = []
|
|
103
106
|
const clean = (s) => redactDraftText(s).redacted.replace(/\s+/g, ' ').trim()
|
|
104
|
-
|
|
107
|
+
// spec 2026-09-08 §2.4 — 사유를 앞에 붙인다. 저장 0건과 비정상 종료는 봐야 할 것이 다르다.
|
|
108
|
+
const why = entry.reason === 'no_writes' ? '저장 0건' : '비정상 종료'
|
|
109
|
+
lines.push(
|
|
110
|
+
`◦ [${entry.runtime}] 세션 ${hashId8(entry.sid)} · ${why} (recovered ${entry.recoveredAt ?? 'unknown'})`
|
|
111
|
+
)
|
|
105
112
|
if (draft.lastUser) lines.push(` 마지막 user: ${clean(draft.lastUser)}`)
|
|
106
113
|
if (draft.lastAssistant) lines.push(` 마지막 assistant: ${clean(draft.lastAssistant)}`)
|
|
107
114
|
if (draft.filePaths.length) lines.push(` 파일 경로: ${draft.filePaths.map((p) => clean(p)).join(' · ')}`)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// spec 2026-09-08-session-continuity-two-gaps.md §2.4 — 저장 0건 세션 감지용 로컬 마커.
|
|
2
|
+
//
|
|
3
|
+
// 서버는 그 세션에 저장이 있었는지 모른다 — `rookie save` 가 세션 식별자를 보내지 않아
|
|
4
|
+
// batch-mutate 를 세션에 귀속시킬 수 없다. 로컬에서 아는 편이 싸고 정확하다.
|
|
5
|
+
//
|
|
6
|
+
// 키에 epoch 를 넣는 이유: session_id 는 /clear·compact 후에도 같은 값이다(그래서 recall-push 가
|
|
7
|
+
// 구간 경계를 skill-gate 의 .epoch 로 따로 감지한다). 세션 단위 키만 쓰면 clear 이전 구간에
|
|
8
|
+
// 저장이 있었다는 이유로 이후 구간이 통째로 면제된다.
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
|
|
14
|
+
export function markerDir() {
|
|
15
|
+
return process.env.ROOKIE_SESSION_WRITES_DIR || join(homedir(), '.rookie', 'session-writes')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function gateDir() {
|
|
19
|
+
return process.env.ROOKIE_SKILL_GATE_DIR || join(homedir(), '.rookie', 'skill-gate')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 세션의 현재 구간 epoch. 파일이 없으면 빈 문자열(현행 recall-push 관행과 동일). */
|
|
23
|
+
export function readEpoch(sessionId) {
|
|
24
|
+
if (!sessionId) return ''
|
|
25
|
+
try {
|
|
26
|
+
const key = createHash('sha256').update(sessionId).digest('hex')
|
|
27
|
+
return String(readFileSync(join(gateDir(), key + '.epoch'), 'utf8')).trim()
|
|
28
|
+
} catch {
|
|
29
|
+
return ''
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function markerKey(sessionId, epoch) {
|
|
34
|
+
return createHash('sha256').update(`${sessionId}:${epoch}`).digest('hex')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 저장 성공 기록. sessionId 미전달이면 조용히 넘어간다(기존 호출자 호환). */
|
|
38
|
+
export function recordSessionWrite(sessionId) {
|
|
39
|
+
if (!sessionId) return false
|
|
40
|
+
try {
|
|
41
|
+
const dir = markerDir()
|
|
42
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
43
|
+
writeFileSync(join(dir, markerKey(sessionId, readEpoch(sessionId))), '', { mode: 0o600 })
|
|
44
|
+
return true
|
|
45
|
+
} catch {
|
|
46
|
+
return false // 마커 실패가 저장을 되돌리지는 않는다
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function hasSessionWrite(sessionId, epoch) {
|
|
51
|
+
if (!sessionId) return true // 판정 불가 — 큐에 넣지 않는다(오탐보다 무동작이 안전)
|
|
52
|
+
try {
|
|
53
|
+
return existsSync(join(markerDir(), markerKey(sessionId, epoch ?? readEpoch(sessionId))))
|
|
54
|
+
} catch {
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -301,6 +301,14 @@ if [ "$HTTP_CODE" = "200" ]; then
|
|
|
301
301
|
if command -v orphan_mark_flushed >/dev/null 2>&1 && [ "${MESSAGE_COUNT:-0}" -gt 0 ] 2>/dev/null; then
|
|
302
302
|
orphan_mark_flushed "$SESSION_ID" "stop" "$MESSAGE_COUNT" || true
|
|
303
303
|
fi
|
|
304
|
+
# spec 2026-09-08 §2.4 — 이 세션 구간에 `rookie save` 성공이 한 번도 없었으면 결정·사실이
|
|
305
|
+
# 어디에도 남지 않는다. transcript 는 있으므로 다음 세션이 건지도록 미추출 큐에 올린다.
|
|
306
|
+
# 내용 없는 세션(MESSAGE_COUNT=0)은 제외 — 건질 게 없다.
|
|
307
|
+
if command -v orphan_session_had_write >/dev/null 2>&1 \
|
|
308
|
+
&& [ "${MESSAGE_COUNT:-0}" -gt 0 ] 2>/dev/null \
|
|
309
|
+
&& ! orphan_session_had_write "$SESSION_ID"; then
|
|
310
|
+
orphan_mark_no_writes "$SESSION_ID" "$TRANSCRIPT_PATH" || true
|
|
311
|
+
fi
|
|
304
312
|
else
|
|
305
313
|
# §17 H-2: 큐 적재 + 1회 chain drain (일시적 깜빡임 회복)
|
|
306
314
|
if command -v queue_enqueue >/dev/null 2>&1; then
|
|
@@ -84,6 +84,48 @@ orphan_mark_baseline() {
|
|
|
84
84
|
"$(printf '{"status":"baseline_skipped","reason":"%s"}' "$reason")"
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
# spec 2026-09-08-session-continuity-two-gaps.md §2.4 — 저장 0건 세션을 미추출 큐에 넣는다.
|
|
88
|
+
#
|
|
89
|
+
# 세션 안에서 `rookie save` 가 한 번도 성공하지 않았으면 그 세션의 결정·사실은 어디에도 없다.
|
|
90
|
+
# transcript 는 남아 있으므로 다음 세션이 orphan-extract 로 건질 수 있게 큐에 올린다.
|
|
91
|
+
#
|
|
92
|
+
# 마커 키에 epoch 를 넣는 이유: session_id 는 /clear·compact 후에도 같은 값이라(그래서 recall-push 가
|
|
93
|
+
# 구간 경계를 .epoch 로 따로 본다), 세션 단위 키만 쓰면 clear 이전 구간의 저장이 이후 구간을 면제한다.
|
|
94
|
+
_orphan_session_write_key() {
|
|
95
|
+
local sid="$1" gate_dir epoch
|
|
96
|
+
gate_dir="${ROOKIE_SKILL_GATE_DIR:-$HOME/.rookie/skill-gate}"
|
|
97
|
+
local sid_hash
|
|
98
|
+
sid_hash="$(printf '%s' "$sid" | shasum -a 256 2>/dev/null | cut -d" " -f1)"
|
|
99
|
+
epoch="$(cat "$gate_dir/$sid_hash.epoch" 2>/dev/null | tr -d "[:space:]")"
|
|
100
|
+
printf '%s:%s' "$sid" "$epoch" | shasum -a 256 2>/dev/null | cut -d" " -f1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# 이 세션 구간에 저장이 있었는가. 판정 불가(빈 sid·shasum 없음)면 "있었다"로 본다 —
|
|
104
|
+
# 무동작이 오탐보다 안전하다.
|
|
105
|
+
orphan_session_had_write() {
|
|
106
|
+
local sid="$1"
|
|
107
|
+
[ -n "$sid" ] || return 0
|
|
108
|
+
command -v shasum >/dev/null 2>&1 || return 0
|
|
109
|
+
local dir="${ROOKIE_SESSION_WRITES_DIR:-$HOME/.rookie/session-writes}"
|
|
110
|
+
local key
|
|
111
|
+
key="$(_orphan_session_write_key "$sid")"
|
|
112
|
+
[ -n "$key" ] || return 0
|
|
113
|
+
[ -f "$dir/$key" ]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# 저장 0건이면 pending-extraction 에 reason=no_writes 로 올린다.
|
|
117
|
+
# 비정상 종료(crash) 사유와 섞이지 않게 reason 을 남긴다 — 섞이면 진짜 복구 대기가 묻힌다.
|
|
118
|
+
orphan_mark_no_writes() {
|
|
119
|
+
local sid="$1" transcript="${2:-}"
|
|
120
|
+
[ -n "$sid" ] || return 1
|
|
121
|
+
# 이미 추출을 마쳤거나 대기 중이면 덮지 않는다.
|
|
122
|
+
[ -f "$(_orphan_client_extracted_dir)/$sid.json" ] && return 0
|
|
123
|
+
[ -f "$(_orphan_pending_dir)/$sid.json" ] && return 0
|
|
124
|
+
_orphan_write_marker "$(_orphan_pending_dir)" "$sid" \
|
|
125
|
+
"$(printf '{"status":"pending_extraction","reason":"no_writes","transcript_path":"%s","recovered_at":"%s"}' \
|
|
126
|
+
"$transcript" "$(date -u +%Y-%m-%dT%H:%M:%SZ)")"
|
|
127
|
+
}
|
|
128
|
+
|
|
87
129
|
# P0.4 — 짧고 내용 없는(low_value) 세션 marker. record_pending 대신 호출 → 재적재 차단.
|
|
88
130
|
orphan_mark_low_value() {
|
|
89
131
|
local sid="$1" reason="${2:-low_value}" mc="${3:-0}"
|
package/templates/skill/SKILL.md
CHANGED
|
@@ -24,6 +24,10 @@ installed_by: __INSTALLED_BY__
|
|
|
24
24
|
- **1순위**: `entry_cards[]` 를 직접 읽는다. 비어있지 않으면 **그 작업을 이어가려는 사용자 의도를 우선 가정**해 먼저 언급한다. 안전 신호 — `grade` `warn`/`old` → 이미 완료됐을 수 있으니 현재 상태부터 점검 / `is_meta_card` → 메타 카드 의심·단일 fact 분리 권고·`body` 절단본(전량 단정 금지) / `is_vague` → 목표 수준 카드 — recall 로 방법 보강 후 착수 / `has_contradiction` → 단정 전 점검 / `reasoning` → 근거로 고려.
|
|
25
25
|
- **activity-stale**: `activity_stale=true`(또는 `newer_activity_count>0`) → 카드 이후 같은 프로젝트에서 세션이 더 진행됐다. `effective_grade`(age·activity 중 높은 등급)를 권위 기준으로, recall + 현재 상태(git log·해당 파일) 점검 후 착수. "이게 마지막 작업" 단정 금지. **단 이 신호는 "카드 이후 활동이 있었다"는 뜻이지 "카드 내용이 틀렸다"는 뜻이 아니다** — 카드를 대체할 내용은 `ephemeral_recovery` 또는 카드 `recorded_at` 보다 최신인 회상 결과로만 확인한다(아래 "진입 카드 = 최신성 기준선").
|
|
26
26
|
- **`ephemeral_recovery`(임시 복구, 저장 아님)**: activity-stale 카드에 붙는 read-time 합성물. `id` 없음 — `supersedes`·`task_id` 연결 금지. stale 카드와 **함께** 표시("직전 카드(오래됨) + 🛟 임시 복구: <headline>"). `synthesis_tier=bare`=세부 기록 없음으로만, `evidence`="바뀐 것으로 보고됨"(확인 아님). 진짜 다음 카드를 저장하면 다음 조회부터 사라진다.
|
|
27
|
+
- **`is_secondary_track`(다른 트랙)**: 이 카드는 주 카드와 **같은 작업의 신·구가 아니라** 같은
|
|
28
|
+
프로젝트에서 병행 중인 별개 작업이다. 한 프로젝트에 트랙이 여럿일 때 최신 트랙이 나머지를 덮는
|
|
29
|
+
것을 막으려고 서버가 골라 붙인다. 두 카드를 신·구로 읽고 한쪽을 폐기하지 않는다 — 어느 쪽을
|
|
30
|
+
이어갈지 애매하면 두목께 한 줄로 여쭙는다.
|
|
27
31
|
- **폴백**(구버전 서버): `system_prompt` 의 `## 🔖 진행 중 작업 카드` 마커를 grep(유동 위치 — head/tail 훑기 금지). 텍스트 경로엔 `🔀 세션 N건`·`🛟 임시 복구` 라인으로 동기.
|
|
28
32
|
- **무-주제 "이어서/계속하자"** 는 `entry_cards`(최신순) 우선. relevance recall 은 특정 주제를 명시했을 때만.
|
|
29
33
|
- **카드 소비 규칙**: `[고정]`·`[통과]` 자산은 폐기·제외 제안 금지 / `[실패]` 접근은 재제안 금지 / "다음:" 이 목표 수준("~이어가기")뿐이면 목표를 되묻지 말고 방법 1개를 추천안 삼아 착수 보고.
|
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
## 호출 방법
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
rookie save --file <body.json>
|
|
8
|
+
rookie save --file <body.json> --session <session_id> # 또는: echo '<body>' | rookie save --session <id>
|
|
9
9
|
```
|
|
10
|
+
|
|
11
|
+
`--session` 은 **세션 시작 컨텍스트에서 받은 session_id** 다. 저장이 성공하면 로컬 마커를 남겨,
|
|
12
|
+
Stop hook 이 "이 세션은 아무것도 저장하지 않았다"를 판정할 수 있게 한다(spec 2026-09-08 §2.4).
|
|
13
|
+
빠뜨려도 저장은 정상 동작하지만, **그 세션은 저장 0건으로 오인돼 미추출 큐에 올라간다** — 붙이는 편이 낫다.
|
|
10
14
|
⚠️ **`curl` 로 직접 batch-mutate 금지** — `rookie save` 가 cwd·git remote·hostname 을 자동 주입해 전역 누수를 막는다. 본문엔 **기억할 내용만**.
|
|
11
15
|
|
|
12
16
|
⚠️ **전역 fact 는 `project_id: null` 을 명시한다.** 식별자도 명시 의사도 없으면 서버가 `missing_workspace_context` 로 **거부**. 미등록 폴더 저장은 **그 폴더에 격리**, `rookie link` 시 자동 승격.
|
package/version.json
CHANGED