skillwiki 0.10.11 → 0.10.13

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.
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ UNMANAGED_END,
4
+ UNMANAGED_START,
3
5
  renderRootIndex,
4
6
  writeRootIndexProjection
5
- } from "./chunk-U34B2XQJ.js";
7
+ } from "./chunk-NMUYMNNB.js";
6
8
  import "./chunk-C5OLZRRM.js";
7
9
  export {
10
+ UNMANAGED_END,
11
+ UNMANAGED_START,
8
12
  renderRootIndex,
9
13
  writeRootIndexProjection
10
14
  };
@@ -2,8 +2,8 @@
2
2
  import {
3
3
  runManagedWritePreflight,
4
4
  runManagedWriteTransaction
5
- } from "./chunk-3ZVZ2YEE.js";
6
- import "./chunk-TYN2IHBY.js";
5
+ } from "./chunk-IIUMTKKA.js";
6
+ import "./chunk-E3PMAHS3.js";
7
7
  import "./chunk-C5OLZRRM.js";
8
8
  export {
9
9
  runManagedWritePreflight,
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runSkillwikiMcpStdio
4
- } from "./chunk-P2FCRGKU.js";
4
+ } from "./chunk-X2CPXF4T.js";
5
5
  import "./chunk-7I2TPIV5.js";
6
- import "./chunk-U34B2XQJ.js";
7
- import "./chunk-TYN2IHBY.js";
6
+ import "./chunk-NMUYMNNB.js";
7
+ import "./chunk-E3PMAHS3.js";
8
8
  import "./chunk-C5OLZRRM.js";
9
9
 
10
10
  // src/mcp-entry.ts
@@ -72,3 +72,30 @@ delete_intent_list_active_paths_from_git() {
72
72
  delete_intent_list_active_paths "$tmp"
73
73
  rm -rf "$tmp"
74
74
  }
75
+
76
+ # Print the sorted unique exact intersection between active tombstone paths and
77
+ # a successful remote inventory. Both inputs are newline-delimited
78
+ # vault-relative path files. This helper is pure: no network, logging, or
79
+ # mutation outside its temporary sort files.
80
+ delete_intent_plan_remote_paths() {
81
+ local active_paths_file="${1:-}"
82
+ local remote_paths_file="${2:-}"
83
+ [ -f "$active_paths_file" ] || return 1
84
+ [ -f "$remote_paths_file" ] || return 1
85
+
86
+ local tmp_dir active_sorted remote_sorted rc
87
+ tmp_dir="$(mktemp -d)" || return 1
88
+ active_sorted="$tmp_dir/active.paths"
89
+ remote_sorted="$tmp_dir/remote.paths"
90
+
91
+ if ! LC_ALL=C sort -u "$active_paths_file" > "$active_sorted" \
92
+ || ! LC_ALL=C sort -u "$remote_paths_file" > "$remote_sorted"; then
93
+ rm -rf "$tmp_dir"
94
+ return 1
95
+ fi
96
+
97
+ LC_ALL=C comm -12 "$active_sorted" "$remote_sorted"
98
+ rc=$?
99
+ rm -rf "$tmp_dir"
100
+ return "$rc"
101
+ }
@@ -38,24 +38,40 @@ vault_sync_op_set_field() {
38
38
  vault_sync_op_set_fields() {
39
39
  local repo="$1" op_id="$2"
40
40
  shift 2
41
- local file tmp
41
+ local file tmp updates source
42
42
  file="$(vault_sync_op_journal_path "$repo" "$op_id")" || return 1
43
43
  tmp="$(mktemp)" || return 1
44
- if [ -f "$file" ]; then
45
- cp "$file" "$tmp" || { rm -f "$tmp"; return 1; }
46
- else
47
- : >"$tmp"
48
- fi
44
+ updates="$(mktemp)" || { rm -f "$tmp"; return 1; }
49
45
  while [ "$#" -ge 2 ]; do
50
- awk -F= -v k="$1" -v v="$2" '
51
- BEGIN{found=0}
52
- $1==k {print k"="v; found=1; next}
53
- {print}
54
- END{if(!found) print k"="v}
55
- ' "$tmp" >"${tmp}.new" || { rm -f "$tmp" "${tmp}.new"; return 1; }
56
- mv "${tmp}.new" "$tmp"
46
+ printf '%s=%s\n' "$1" "$2" >>"$updates" || { rm -f "$tmp" "$updates"; return 1; }
57
47
  shift 2
58
48
  done
49
+ source="$file"
50
+ [ -f "$source" ] || source=/dev/null
51
+ awk -F= '
52
+ FNR==NR {
53
+ key=$1
54
+ if (!(key in replacement)) order[++count]=key
55
+ replacement[key]=substr($0, index($0,"=")+1)
56
+ next
57
+ }
58
+ {
59
+ key=$1
60
+ if (key in replacement) {
61
+ print key"="replacement[key]
62
+ seen[key]=1
63
+ next
64
+ }
65
+ print
66
+ }
67
+ END {
68
+ for (i=1; i<=count; i++) {
69
+ key=order[i]
70
+ if (!(key in seen)) print key"="replacement[key]
71
+ }
72
+ }
73
+ ' "$updates" "$source" >"$tmp" || { rm -f "$tmp" "$updates"; return 1; }
74
+ rm -f "$updates"
59
75
  mv "$tmp" "$file"
60
76
  }
61
77
 
@@ -160,6 +176,83 @@ vault_sync_op_find_review_required() {
160
176
  return 1
161
177
  }
162
178
 
179
+ # Mark one obsolete review-required handoff complete while preserving its audit
180
+ # trail. Caller must establish that no live owner is mutating the journal.
181
+ vault_sync_op_mark_superseded_stale_review_required() {
182
+ local repo="$1" op_id="$2" by="${3:-vault-sync-managed-preflight}"
183
+ local reason="$4" prior_reason="$5" now
184
+ if [ -z "$prior_reason" ] && [ -n "$reason" ] && [ "$reason" != "superseded-stale-review-required" ]; then
185
+ prior_reason="$reason"
186
+ fi
187
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%dT%H:%M:%S)"
188
+ vault_sync_op_set_fields "$repo" "$op_id" \
189
+ "phase" "complete" \
190
+ "handoff" "1" \
191
+ "reason" "superseded-stale-review-required" \
192
+ "prior_reason" "$prior_reason" \
193
+ "superseded_at" "$now" \
194
+ "cleared_by" "$by" \
195
+ "cleared_reason" "operator-or-preflight $now"
196
+ }
197
+
198
+ # Auto-supersede review-required handoffs whose target is already an ancestor
199
+ # of HEAD. Unrelated dirty WIP is preserved; active sequencers and unmerged
200
+ # paths fail closed. Returns nonzero while any applicable handoff remains open.
201
+ vault_sync_op_supersede_stale_review_required() {
202
+ local repo="$1" by="${2:-vault-sync-managed-preflight}"
203
+ local current_git_dir git_dir unmerged jdir jf op_id phase handoff journal_git_dir target unresolved
204
+ local reason prior_reason key value
205
+
206
+ current_git_dir="$(git -C "$repo" rev-parse --absolute-git-dir 2>/dev/null)" || return 1
207
+ git_dir="$current_git_dir"
208
+ if [ -d "$git_dir/rebase-merge" ] || [ -d "$git_dir/rebase-apply" ] \
209
+ || [ -f "$git_dir/MERGE_HEAD" ] || [ -f "$git_dir/CHERRY_PICK_HEAD" ] \
210
+ || [ -f "$git_dir/REVERT_HEAD" ]; then
211
+ return 1
212
+ fi
213
+ unmerged="$(git -C "$repo" ls-files -u 2>/dev/null | head -1 || true)"
214
+ [ -z "$unmerged" ] || return 1
215
+
216
+ jdir="$(vault_sync_op_journal_dir "$repo")" || return 1
217
+ [ -d "$jdir" ] || return 0
218
+ unresolved=""
219
+ for jf in "$jdir"/*.env; do
220
+ [ -f "$jf" ] || continue
221
+ op_id="$(basename "$jf" .env)"
222
+ phase=""
223
+ handoff=""
224
+ journal_git_dir=""
225
+ target=""
226
+ reason=""
227
+ prior_reason=""
228
+ while IFS='=' read -r key value; do
229
+ case "$key" in
230
+ phase) phase="$value" ;;
231
+ handoff) handoff="$value" ;;
232
+ worktree_git_dir) journal_git_dir="$value" ;;
233
+ target_oid) target="$value" ;;
234
+ reason) reason="$value" ;;
235
+ prior_reason) prior_reason="$value" ;;
236
+ esac
237
+ done <"$jf"
238
+ [ "$phase" = "review-required" ] && [ "$handoff" = "1" ] || continue
239
+ if [ -n "$journal_git_dir" ] && [ "$journal_git_dir" != "$current_git_dir" ]; then
240
+ continue
241
+ fi
242
+ if [ -z "$target" ]; then
243
+ [ -n "$unresolved" ] || unresolved="$op_id"
244
+ continue
245
+ fi
246
+ if git -C "$repo" merge-base --is-ancestor "$target" HEAD 2>/dev/null; then
247
+ vault_sync_op_mark_superseded_stale_review_required \
248
+ "$repo" "$op_id" "$by" "$reason" "$prior_reason" || return 1
249
+ else
250
+ [ -n "$unresolved" ] || unresolved="$op_id"
251
+ fi
252
+ done
253
+ [ -z "$unresolved" ]
254
+ }
255
+
163
256
  vault_sync_op_preflight_blocker() {
164
257
  local repo="$1" unmerged op_id git_dir
165
258
  unmerged="$(git -C "$repo" diff --name-only --diff-filter=U 2>/dev/null || true)"
@@ -62,7 +62,7 @@ vault_sync_managed_lock_safe_to_reclaim() {
62
62
 
63
63
  # Never reclaim during an active/leftover sequencer.
64
64
  if [ -d "$git_dir/rebase-merge" ] || [ -d "$git_dir/rebase-apply" ] \
65
- || [ -d "$git_dir/MERGE_HEAD" ] || [ -f "$git_dir/CHERRY_PICK_HEAD" ] \
65
+ || [ -f "$git_dir/MERGE_HEAD" ] || [ -f "$git_dir/CHERRY_PICK_HEAD" ] \
66
66
  || [ -f "$git_dir/REVERT_HEAD" ]; then
67
67
  return 1
68
68
  fi
@@ -72,13 +72,16 @@ vault_sync_managed_lock_safe_to_reclaim() {
72
72
  return 1
73
73
  fi
74
74
 
75
- # When journal helpers are loaded, refuse reclaim while review-required handoff
76
- # journals exist. Standalone lock tests may not source the journal library.
77
- if command -v vault_sync_op_find_review_required >/dev/null 2>&1; then
75
+ # When journal helpers are loaded, a dead owner may close handoffs whose
76
+ # target is already in HEAD, then continue reclaim in this same acquire.
77
+ # Live owners never reach this function; active sequencers/unmerged paths
78
+ # already failed closed above. Any unresolved handoff still blocks reclaim.
79
+ if command -v vault_sync_op_supersede_stale_review_required >/dev/null 2>&1; then
80
+ vault_sync_op_supersede_stale_review_required \
81
+ "$repo" "vault-sync-managed-lock-reclaim" 2>/dev/null || return 1
82
+ elif command -v vault_sync_op_find_review_required >/dev/null 2>&1; then
78
83
  review_op="$(vault_sync_op_find_review_required "$repo" 2>/dev/null || true)"
79
- if [ -n "$review_op" ]; then
80
- return 1
81
- fi
84
+ [ -z "$review_op" ] || return 1
82
85
  fi
83
86
 
84
87
  return 0
@@ -78,9 +78,11 @@ LOG_FILE="${WIKI_SNAPSHOT_LOG:-$DEFAULT_LOG_DIR/wiki-snapshot.log}"
78
78
  CLOUD_REMOTE="${CLOUD_REMOTE:-cloud:cloud/wiki}"
79
79
  REPAIR_SCRIPT="${WIKI_GIT_REPAIR_SCRIPT:-$SCRIPT_DIR/wiki-git-repair-v3.sh}"
80
80
  MAX_S3_ONLY_NOTES="${WIKI_SNAPSHOT_MAX_S3_ONLY_NOTES:-200}"
81
+ MAX_TOMBSTONE_PRUNES="${WIKI_SNAPSHOT_MAX_TOMBSTONE_PRUNES:-10}"
81
82
  DATE=$(date +%Y%m%d_%H%M%S)
82
83
  RCLONE_LOG="/tmp/rclone-${DATE}.log"
83
84
  SNAPSHOT_DIRECT_S3_NOT_GIT_COUNT=0
85
+ SNAPSHOT_REMOTE_INVENTORY_READY=0
84
86
 
85
87
  fetch_origin_main_ref() {
86
88
  # The snapshot guards read origin/main directly. Use an explicit destination
@@ -88,25 +90,63 @@ fetch_origin_main_ref() {
88
90
  git fetch --quiet origin +refs/heads/main:refs/remotes/origin/main
89
91
  }
90
92
 
93
+ snapshot_refresh_origin_main() {
94
+ (
95
+ cd "$SNAPSHOT_WORKTREE" || exit 1
96
+ fetch_origin_main_ref
97
+ )
98
+ }
99
+
100
+ snapshot_load_active_delete_intent_paths() {
101
+ local output_file="${1:-}"
102
+ [ -n "$output_file" ] || return 1
103
+ : > "$output_file" || return 1
104
+
105
+ if command -v delete_intent_list_active_paths_from_git >/dev/null 2>&1 \
106
+ && git -C "$SNAPSHOT_WORKTREE" rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
107
+ delete_intent_list_active_paths_from_git "$SNAPSHOT_WORKTREE" origin/main > "$output_file"
108
+ return $?
109
+ fi
110
+ if command -v delete_intent_list_active_paths >/dev/null 2>&1; then
111
+ delete_intent_list_active_paths "$SNAPSHOT_WORKTREE" > "$output_file"
112
+ return $?
113
+ fi
114
+ return 1
115
+ }
116
+
91
117
  snapshot_direct_s3_preflight() {
118
+ local inventory_output="${1:-}"
119
+ local active_paths_file="${2:-}"
92
120
  local tmp_dir
93
- tmp_dir="$(mktemp -d)"
94
- # Ensure tmp_dir is cleaned up on any return path or interrupt.
95
- trap 'rm -rf "$tmp_dir"' RETURN
96
- local direct_paths="$tmp_dir/direct-s3.paths"
121
+ tmp_dir="$(mktemp -d)" || {
122
+ echo "[wiki-snapshot] WARN: direct-S3 preflight could not create temporary state"
123
+ SNAPSHOT_REMOTE_INVENTORY_READY=0
124
+ return 0
125
+ }
126
+ local direct_paths
127
+ if [ -n "$inventory_output" ]; then
128
+ direct_paths="$inventory_output"
129
+ else
130
+ direct_paths="$tmp_dir/direct-s3.paths"
131
+ fi
97
132
  local direct_notes="$tmp_dir/direct-s3-notes.paths"
98
133
  local git_paths="$tmp_dir/git.paths"
134
+ local direct_not_git_candidates="$tmp_dir/direct-s3-not-git-candidates.paths"
99
135
  local direct_not_git="$tmp_dir/direct-s3-not-git.paths"
136
+ local active_sorted="$tmp_dir/active.paths"
137
+
138
+ SNAPSHOT_REMOTE_INVENTORY_READY=0
100
139
 
101
- if ! rclone lsf "$CLOUD_REMOTE" --recursive --files-only 2>/dev/null | LC_ALL=C sort > "$direct_paths"; then
140
+ if ! rclone lsf "$CLOUD_REMOTE" --recursive --files-only 2>/dev/null | LC_ALL=C sort -u > "$direct_paths"; then
102
141
  echo "[wiki-snapshot] WARN: direct-S3 preflight could not list $CLOUD_REMOTE"
142
+ rm -rf "$tmp_dir"
103
143
  return 0
104
144
  fi
145
+ SNAPSHOT_REMOTE_INVENTORY_READY=1
105
146
 
106
147
  grep -vE '^(\.skillwiki/|\.claude/|\.obsidian/|\.antigravitycli/|\.playwright-cli/|raw/\._\.DS_Store$|\._\.DS_Store$)' "$direct_paths" | LC_ALL=C sort -u > "$direct_notes" || true
107
148
  (
108
149
  cd "$SNAPSHOT_WORKTREE" || exit 1
109
- fetch_origin_main_ref 2>/dev/null || true
110
150
  if git rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
111
151
  git -c core.quotePath=false ls-tree -r --name-only origin/main
112
152
  else
@@ -114,23 +154,48 @@ snapshot_direct_s3_preflight() {
114
154
  fi | LC_ALL=C sort -u
115
155
  ) > "$git_paths" 2>/dev/null || {
116
156
  echo "[wiki-snapshot] WARN: direct-S3 preflight could not list tracked files in snapshot worktree $SNAPSHOT_WORKTREE"
157
+ rm -rf "$tmp_dir"
117
158
  return 0
118
159
  }
119
160
 
120
- LC_ALL=C comm -23 "$direct_notes" "$git_paths" > "$direct_not_git"
161
+ LC_ALL=C comm -23 "$direct_notes" "$git_paths" > "$direct_not_git_candidates"
162
+ if [ -n "$active_paths_file" ] && [ -f "$active_paths_file" ]; then
163
+ LC_ALL=C sort -u "$active_paths_file" > "$active_sorted"
164
+ LC_ALL=C comm -23 "$direct_not_git_candidates" "$active_sorted" > "$direct_not_git"
165
+ else
166
+ LC_ALL=C sort -u "$direct_not_git_candidates" > "$direct_not_git"
167
+ fi
121
168
  SNAPSHOT_DIRECT_S3_NOT_GIT_COUNT="$(wc -l < "$direct_not_git" | tr -d ' ')"
122
169
  if [ "$SNAPSHOT_DIRECT_S3_NOT_GIT_COUNT" != "0" ]; then
123
170
  echo "[wiki-snapshot] WARN: direct-S3-not-git warning: $SNAPSHOT_DIRECT_S3_NOT_GIT_COUNT note path(s) exist in direct S3 but not in $SNAPSHOT_WORKTREE"
124
171
  sed -n '1,20p' "$direct_not_git" | sed 's/^/[wiki-snapshot] WARN: direct-S3-not-git: /'
172
+ rm -rf "$tmp_dir"
125
173
  return 2
126
174
  fi
127
175
 
176
+ rm -rf "$tmp_dir"
128
177
  return 0
129
178
  }
130
179
 
131
180
  handle_direct_s3_preflight_before_sync() {
132
- snapshot_direct_s3_preflight
181
+ local tmp_dir active_paths
182
+ tmp_dir="$(mktemp -d)" || {
183
+ log "ERROR: could not create direct-S3 preflight state"
184
+ return 1
185
+ }
186
+ active_paths="$tmp_dir/active.paths"
187
+ if ! snapshot_refresh_origin_main 2>/dev/null; then
188
+ log "WARNING: could not refresh origin/main before direct-S3 preflight; using the existing ref"
189
+ fi
190
+ if ! snapshot_load_active_delete_intent_paths "$active_paths"; then
191
+ rm -rf "$tmp_dir"
192
+ log "ERROR: active delete-intent inventory unavailable before direct-S3 preflight"
193
+ return 1
194
+ fi
195
+
196
+ snapshot_direct_s3_preflight "" "$active_paths"
133
197
  local rc=$?
198
+ rm -rf "$tmp_dir"
134
199
  if [ "$rc" -eq 0 ]; then
135
200
  return 0
136
201
  fi
@@ -197,7 +262,34 @@ if [ "$DRY_RUN" = true ]; then
197
262
  echo " CLOUD_REMOTE = $CLOUD_REMOTE"
198
263
  echo " REPAIR_SCRIPT = $REPAIR_SCRIPT"
199
264
  echo "[wiki-snapshot] DRY RUN: --max-delete guard verified (present in $0)"
200
- snapshot_direct_s3_preflight || true
265
+ dry_run_tmp="$(mktemp -d)" || exit 1
266
+ dry_run_active="$dry_run_tmp/active.paths"
267
+ dry_run_remote="$dry_run_tmp/remote.paths"
268
+ dry_run_plan="$dry_run_tmp/remote-present.paths"
269
+ case "$MAX_TOMBSTONE_PRUNES" in
270
+ ''|*[!0-9]*)
271
+ echo "[wiki-snapshot] ERROR: invalid WIKI_SNAPSHOT_MAX_TOMBSTONE_PRUNES=$MAX_TOMBSTONE_PRUNES; expected a non-negative integer" >&2
272
+ rm -rf "$dry_run_tmp"
273
+ exit 1
274
+ ;;
275
+ esac
276
+ snapshot_refresh_origin_main 2>/dev/null || true
277
+ snapshot_load_active_delete_intent_paths "$dry_run_active" || : > "$dry_run_active"
278
+ snapshot_direct_s3_preflight "$dry_run_remote" "$dry_run_active" || true
279
+ if [ "$SNAPSHOT_REMOTE_INVENTORY_READY" = "1" ] \
280
+ && delete_intent_plan_remote_paths "$dry_run_active" "$dry_run_remote" > "$dry_run_plan"; then
281
+ dry_run_active_count="$(wc -l < "$dry_run_active" | tr -d ' ')"
282
+ dry_run_remote_count="$(wc -l < "$dry_run_plan" | tr -d ' ')"
283
+ dry_run_absent_count=$((dry_run_active_count - dry_run_remote_count))
284
+ dry_run_deferred_count=0
285
+ if [ "$dry_run_remote_count" -gt "$MAX_TOMBSTONE_PRUNES" ] 2>/dev/null; then
286
+ dry_run_deferred_count=$((dry_run_remote_count - MAX_TOMBSTONE_PRUNES))
287
+ fi
288
+ echo "[wiki-snapshot] DRY RUN: delete-intent active=$dry_run_active_count remote_present=$dry_run_remote_count already_absent=$dry_run_absent_count deferred=$dry_run_deferred_count"
289
+ else
290
+ echo "[wiki-snapshot] DRY RUN: delete-intent remote inventory unavailable; optional pruning would be skipped"
291
+ fi
292
+ rm -rf "$dry_run_tmp"
201
293
  echo "[wiki-snapshot] DRY RUN: would acquire $LOCK_FILE, rclone sync, git commit, push."
202
294
  echo "[wiki-snapshot] DRY RUN: Complete. No changes made."
203
295
  exit 0
@@ -217,6 +309,16 @@ log() {
217
309
  echo "$(date '+%Y-%m-%d %H:%M:%S') $1" | tee -a "$LOG_FILE"
218
310
  }
219
311
 
312
+ validate_tombstone_prune_cap() {
313
+ case "$MAX_TOMBSTONE_PRUNES" in
314
+ ''|*[!0-9]*)
315
+ log "ERROR: invalid WIKI_SNAPSHOT_MAX_TOMBSTONE_PRUNES=$MAX_TOMBSTONE_PRUNES; expected a non-negative integer"
316
+ return 1
317
+ ;;
318
+ esac
319
+ return 0
320
+ }
321
+
220
322
  raw_dedup_guard() {
221
323
  if [ "${WIKI_SNAPSHOT_RAW_DEDUP_GUARD:-1}" = "0" ]; then
222
324
  log "raw_dedup guard skipped by WIKI_SNAPSHOT_RAW_DEDUP_GUARD=0"
@@ -293,6 +395,10 @@ refresh_git_baseline() {
293
395
 
294
396
  log "=== Wiki Snapshot: $DATE ==="
295
397
 
398
+ if ! validate_tombstone_prune_cap; then
399
+ exit 1
400
+ fi
401
+
296
402
  # Check disk space (need at least 100MB free)
297
403
  AVAILABLE_KB=$(df -k "$SNAPSHOT_WORKTREE" | awk 'NR==2 {print $4}')
298
404
  if [ "$AVAILABLE_KB" -lt 102400 ]; then
@@ -392,61 +498,156 @@ fi
392
498
  # --- Delete-intent no-resurrect ---
393
499
  # Git is SSOT for intentional absences. After S3→worktree sync, strip any path
394
500
  # that has an active tombstone on origin/main and optionally prune S3.
501
+ snapshot_remote_path_exists_exact() {
502
+ local rel_path="${1:-}"
503
+ local parent_path base_name remote_parent output_file rc
504
+ [ -n "$rel_path" ] || return 2
505
+
506
+ base_name="${rel_path##*/}"
507
+ if [ "$rel_path" = "$base_name" ]; then
508
+ remote_parent="${CLOUD_REMOTE%/}"
509
+ else
510
+ parent_path="${rel_path%/*}"
511
+ remote_parent="${CLOUD_REMOTE%/}/$parent_path"
512
+ fi
513
+
514
+ output_file="$(mktemp)" || return 2
515
+ if ! rclone lsf "$remote_parent" --files-only --max-depth 1 --retries 1 > "$output_file" 2>>"$LOG_FILE"; then
516
+ rm -f "$output_file"
517
+ return 2
518
+ fi
519
+ if grep -Fxq -- "$base_name" "$output_file"; then
520
+ rc=0
521
+ else
522
+ rc=1
523
+ fi
524
+ rm -f "$output_file"
525
+ return "$rc"
526
+ }
527
+
395
528
  snapshot_apply_delete_intents() {
396
- if ! command -v delete_intent_list_active_paths_from_git >/dev/null 2>&1 \
397
- && ! command -v delete_intent_list_active_paths >/dev/null 2>&1; then
398
- log "WARNING: delete-intent helpers unavailable; skipping tombstone no-resurrect"
529
+ local paths_file="${1:-}"
530
+ local remote_inventory_file="${2:-}"
531
+ local inventory_ready="${3:-0}"
532
+ if [ ! -f "$paths_file" ]; then
533
+ log "ERROR: active delete-intent inventory file missing; refusing snapshot promotion"
534
+ return 1
535
+ fi
536
+
537
+ if [ ! -s "$paths_file" ]; then
538
+ log "delete-intent: no active tombstones"
399
539
  return 0
400
540
  fi
401
541
 
402
542
  # Re-materialize ledger from git so rclone sync cannot drop git-only intents,
403
- # then list from the worktree (single parse avoid re-git-show of every blob).
404
- (
405
- cd "$SNAPSHOT_WORKTREE" || exit 0
406
- fetch_origin_main_ref 2>/dev/null || true
543
+ # and fail closed if the authoritative ledger cannot be restored.
544
+ if ! (
545
+ cd "$SNAPSHOT_WORKTREE" || exit 1
407
546
  if git rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
408
- git checkout origin/main -- meta/delete-intents/ 2>/dev/null || true
547
+ git checkout origin/main -- meta/delete-intents/
409
548
  fi
410
- )
549
+ ) 2>>"$LOG_FILE"; then
550
+ log "ERROR: could not restore authoritative delete-intent ledger from origin/main"
551
+ return 1
552
+ fi
411
553
 
412
- local paths_file pruned=0
413
- paths_file="$(mktemp)"
414
- if command -v delete_intent_list_active_paths >/dev/null 2>&1; then
415
- delete_intent_list_active_paths "$SNAPSHOT_WORKTREE" > "$paths_file" 2>/dev/null || true
416
- elif command -v delete_intent_list_active_paths_from_git >/dev/null 2>&1; then
417
- delete_intent_list_active_paths_from_git "$SNAPSHOT_WORKTREE" origin/main > "$paths_file" 2>/dev/null || true
554
+ local active_count remote_present_count attempted pruned already_absent failed deferred
555
+ local rel_path plan_file recheck_rc
556
+ active_count="$(wc -l < "$paths_file" | tr -d ' ')"
557
+ remote_present_count=0
558
+ attempted=0
559
+ pruned=0
560
+ already_absent=0
561
+ failed=0
562
+ deferred=0
563
+
564
+ while IFS= read -r rel_path; do
565
+ [ -z "$rel_path" ] && continue
566
+ if [ -e "$SNAPSHOT_WORKTREE/$rel_path" ]; then
567
+ rm -f -- "$SNAPSHOT_WORKTREE/$rel_path"
568
+ log "delete-intent: stripped resurrected path from worktree: $rel_path"
569
+ fi
570
+ done < "$paths_file"
571
+
572
+ if [ "$inventory_ready" != "1" ] || [ ! -f "$remote_inventory_file" ]; then
573
+ log "WARNING: delete-intent remote inventory unavailable; optional S3 pruning skipped"
574
+ log "delete-intent: no-resurrect pass complete active=$active_count inventory_ready=0 remote_present=0 attempted=0 pruned=0 already_absent=0 failed=0 deferred=0"
575
+ return 0
418
576
  fi
419
577
 
420
- if [ ! -s "$paths_file" ]; then
421
- rm -f "$paths_file"
422
- log "delete-intent: no active tombstones"
578
+ plan_file="$(mktemp)" || {
579
+ log "WARNING: could not create delete-intent prune plan; optional S3 pruning skipped"
580
+ return 0
581
+ }
582
+ if ! delete_intent_plan_remote_paths "$paths_file" "$remote_inventory_file" > "$plan_file"; then
583
+ rm -f "$plan_file"
584
+ log "WARNING: could not plan delete-intent remote intersection; optional S3 pruning skipped"
423
585
  return 0
424
586
  fi
425
587
 
426
- local rel_path
588
+ remote_present_count="$(wc -l < "$plan_file" | tr -d ' ')"
589
+ already_absent=$((active_count - remote_present_count))
590
+
427
591
  while IFS= read -r rel_path; do
428
592
  [ -z "$rel_path" ] && continue
429
- if [ -e "$SNAPSHOT_WORKTREE/$rel_path" ]; then
430
- rm -f "$SNAPSHOT_WORKTREE/$rel_path"
431
- log "delete-intent: stripped resurrected path from worktree: $rel_path"
593
+ if [ "$attempted" -ge "$MAX_TOMBSTONE_PRUNES" ]; then
594
+ break
432
595
  fi
433
- # Bounded S3 prune for tombstoned paths still present on remote.
434
- if [ "$pruned" -lt 10 ]; then
435
- if rclone deletefile "${CLOUD_REMOTE%/}/$rel_path" >>"$LOG_FILE" 2>&1; then
436
- log "delete-intent: pruned S3 object $rel_path"
437
- pruned=$((pruned + 1))
438
- fi
596
+ attempted=$((attempted + 1))
597
+ if rclone deletefile "${CLOUD_REMOTE%/}/$rel_path" --retries 1 >>"$LOG_FILE" 2>&1; then
598
+ pruned=$((pruned + 1))
599
+ log "delete-intent: pruned S3 object $rel_path"
600
+ continue
439
601
  fi
440
- done < "$paths_file"
441
602
 
442
- rm -f "$paths_file"
443
- log "delete-intent: no-resurrect pass complete (s3_pruned=$pruned)"
603
+ snapshot_remote_path_exists_exact "$rel_path"
604
+ recheck_rc=$?
605
+ if [ "$recheck_rc" -eq 1 ]; then
606
+ already_absent=$((already_absent + 1))
607
+ log "delete-intent: delete race resolved as already absent: $rel_path"
608
+ elif [ "$recheck_rc" -eq 0 ]; then
609
+ failed=$((failed + 1))
610
+ log "WARNING: delete-intent prune failed and object remains present: $rel_path"
611
+ else
612
+ failed=$((failed + 1))
613
+ log "WARNING: delete-intent prune failed and exact recheck was unavailable: $rel_path"
614
+ fi
615
+ done < "$plan_file"
616
+
617
+ deferred=$((remote_present_count - attempted))
618
+ rm -f "$plan_file"
619
+ log "delete-intent: no-resurrect pass complete active=$active_count inventory_ready=1 remote_present=$remote_present_count attempted=$attempted pruned=$pruned already_absent=$already_absent failed=$failed deferred=$deferred"
444
620
  return 0
445
621
  }
446
622
 
447
- snapshot_apply_delete_intents
623
+ snapshot_reconcile_delete_intents() {
624
+ local tmp_dir active_paths remote_inventory rc
625
+ tmp_dir="$(mktemp -d)" || {
626
+ log "ERROR: could not create delete-intent reconciliation state"
627
+ return 1
628
+ }
629
+ active_paths="$tmp_dir/active.paths"
630
+ remote_inventory="$tmp_dir/remote.paths"
631
+
632
+ if ! snapshot_refresh_origin_main 2>/dev/null; then
633
+ log "WARNING: could not refresh origin/main before delete-intent reconciliation; using the existing ref"
634
+ fi
635
+ if ! snapshot_load_active_delete_intent_paths "$active_paths"; then
636
+ rm -rf "$tmp_dir"
637
+ log "ERROR: could not load active delete-intent paths; refusing snapshot promotion"
638
+ return 1
639
+ fi
448
640
 
449
- snapshot_direct_s3_preflight || true
641
+ snapshot_direct_s3_preflight "$remote_inventory" "$active_paths" || true
642
+ snapshot_apply_delete_intents "$active_paths" "$remote_inventory" "$SNAPSHOT_REMOTE_INVENTORY_READY"
643
+ rc=$?
644
+ rm -rf "$tmp_dir"
645
+ return "$rc"
646
+ }
647
+
648
+ if ! snapshot_reconcile_delete_intents; then
649
+ exit 1
650
+ fi
450
651
 
451
652
  # Change to git dir for operations
452
653
  cd "$SNAPSHOT_WORKTREE" || { log "ERROR: Failed to cd to $SNAPSHOT_WORKTREE"; exit 1; }
@@ -514,7 +715,9 @@ if [ "$needs_repair" = true ]; then
514
715
  exit 1
515
716
  fi
516
717
  rm -f "$RCLONE_LOG"
517
- snapshot_apply_delete_intents
718
+ if ! snapshot_reconcile_delete_intents; then
719
+ exit 1
720
+ fi
518
721
  fi
519
722
 
520
723
  if ! raw_dedup_guard; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.10.11",
3
+ "version": "0.10.13",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "skillwiki": "dist/cli.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.10.11",
3
+ "version": "0.10.13",
4
4
  "skills": "./",
5
5
  "description": "Project-aware Karpathy-style knowledge base for Claude Code: 19 prompt-only skills (wiki-*, proj-*, using-skillwiki) backed by the deterministic `skillwiki` CLI.",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.10.11",
3
+ "version": "0.10.13",
4
4
  "description": "Project-aware Karpathy-style knowledge base for Codex with 19 prompt-only skills backed by the deterministic skillwiki CLI.",
5
5
  "author": {
6
6
  "name": "karlorz",