pwn 0.5.762 → 0.5.766

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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +9 -9
  3. data/documentation/Banner.md +18 -0
  4. data/documentation/Installation.md +104 -0
  5. data/documentation/Reinforcement-Learning.md +80 -0
  6. data/documentation/pwn-REPL.md +74 -0
  7. data/etc/default_cron/session_cleanup.rb +255 -0
  8. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +9 -2
  9. data/etc/default_skills/pwn/plugins/repl/SKILL.md +8 -0
  10. data/etc/default_skills/pwn/sessions/SKILL.md +10 -0
  11. data/lib/pwn/ai/agent/curriculum.rb +9 -2
  12. data/lib/pwn/ai/agent/learning.rb +365 -0
  13. data/lib/pwn/ai/agent/loop.rb +4 -0
  14. data/lib/pwn/ai/agent/reward.rb +12 -1
  15. data/lib/pwn/ai/agent/swarm.rb +10 -3
  16. data/lib/pwn/ai/agent/tools/learning.rb +20 -3
  17. data/lib/pwn/ai/agent/tools/sessions.rb +2 -1
  18. data/lib/pwn/ai/anthropic.rb +1 -1
  19. data/lib/pwn/ai/cli.rb +5 -2
  20. data/lib/pwn/ai/grok.rb +1 -1
  21. data/lib/pwn/ai/open_ai.rb +1 -1
  22. data/lib/pwn/ai/router.rb +56 -12
  23. data/lib/pwn/cron.rb +35 -1
  24. data/lib/pwn/migrate.rb +5 -1
  25. data/lib/pwn/plugins/repl/ai.rb +176 -31
  26. data/lib/pwn/plugins/repl/ai_console.rb +173 -16
  27. data/lib/pwn/plugins/repl/mesh.rb +73 -10
  28. data/lib/pwn/plugins/repl.rb +9 -1
  29. data/lib/pwn/sessions.rb +567 -22
  30. data/lib/pwn/version.rb +1 -1
  31. data/spec/lib/pwn/ai/agent/learning_spec.rb +229 -0
  32. data/spec/lib/pwn/ai/agent/swarm_spec.rb +10 -1
  33. data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +14 -0
  34. data/spec/lib/pwn/ai/cli_spec.rb +15 -1
  35. data/spec/lib/pwn/ai/router_spec.rb +53 -0
  36. data/spec/lib/pwn/banner_spec.rb +1 -1
  37. data/spec/lib/pwn/cron_spec.rb +4 -3
  38. data/spec/lib/pwn/migrate_spec.rb +4 -4
  39. data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +9 -0
  40. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +400 -7
  41. data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +210 -0
  42. data/spec/lib/pwn/plugins/repl/ai_spec.rb +209 -0
  43. data/spec/lib/pwn/plugins/repl_mesh_transport_label_spec.rb +236 -0
  44. data/spec/lib/pwn/plugins/repl_spec.rb +2 -0
  45. data/spec/lib/pwn/redaction_spec.rb +3 -2
  46. data/spec/lib/pwn/session_cleanup_default_spec.rb +72 -0
  47. data/spec/lib/pwn/session_cleanup_payload_spec.rb +593 -0
  48. data/spec/lib/pwn/sessions_spec.rb +344 -0
  49. data/third_party/pwn_rdoc.jsonl +52 -1
  50. metadata +24 -19
@@ -64,10 +64,18 @@ PWN::Plugins::REPL.ready_tty(opts)
64
64
  - `pwn_ai_list_llms`
65
65
  - `pwn_ai_memory_command`
66
66
  - `pwn_ai_model_ids`
67
+ - `pwn_ai_print_result`
67
68
  - `pwn_ai_profile_command`
68
69
  - `pwn_ai_prompt_reasoning`
69
70
  - `pwn_ai_provider_class`
70
71
  - `pwn_ai_reasoning_selection`
72
+ - `pwn_ai_result_chronology`
73
+ - `pwn_ai_result_lines`
74
+ - `pwn_ai_result_order`
75
+ - `pwn_ai_result_record_time`
76
+ - `pwn_ai_result_scalar`
77
+ - `pwn_ai_result_text`
78
+ - `pwn_ai_result_time`
71
79
  - `pwn_ai_run_cron`
72
80
  - `pwn_ai_run_learning`
73
81
  - `pwn_ai_run_mcp`
@@ -35,7 +35,16 @@ PWN::Sessions.sessions_dir(opts)
35
35
 
36
36
  - `sessions_dir`
37
37
  - `list`
38
+ - `dependency_index`
39
+ - `activate`
40
+ - `deactivate`
41
+ - `active`
38
42
  - `create`
43
+ - `metadata`
44
+ - `title_status`
45
+ - `resolve`
46
+ - `provisional_title`
47
+ - `refresh_title`
39
48
  - `append`
40
49
  - `load`
41
50
  - `previous_id`
@@ -51,6 +60,7 @@ PWN::Sessions.sessions_dir(opts)
51
60
  - `redact`
52
61
  - `authors`
53
62
  - `help`
63
+ - `active?`
54
64
  - `lean!`
55
65
 
56
66
  ## Source
@@ -241,6 +241,10 @@ module PWN
241
241
  Dir[File.join(dir, '*.jsonl')].sort_by { |f| -File.mtime(f).to_i }.first(limit * 2).map { |f| File.basename(f, '.jsonl') }
242
242
  end
243
243
 
244
+ # Session cleanup removes directory discovery, not the learning corpus.
245
+ evidence_rows = defined?(Learning) ? Learning.outcomes(limit: 100_000).select { |row| row[:evidence_ref] } : []
246
+ evidence_times = evidence_rows.to_h { |row| [row[:session_id], row[:timestamp]] }
247
+ sids = (sids + evidence_times.keys).uniq
244
248
  scored = []
245
249
  sids.first(limit * 3).each do |sid|
246
250
  break if scored.length >= limit
@@ -257,7 +261,7 @@ module PWN
257
261
  (PWN::Sessions::SESSIONS_DIR if defined?(PWN::Sessions::SESSIONS_DIR)) || File.join(Dir.home, '.pwn', 'sessions'),
258
262
  "#{sid}.jsonl"
259
263
  )
260
- File.exist?(path) ? File.mtime(path).utc : nil
264
+ File.exist?(path) ? File.mtime(path).utc : (Time.parse(evidence_times[sid]) if evidence_times[sid])
261
265
  rescue StandardError
262
266
  nil
263
267
  end
@@ -1018,7 +1022,8 @@ module PWN
1018
1022
  end
1019
1023
 
1020
1024
  private_class_method def self.self_play(opts = {})
1021
- sid = PWN::Sessions.create(title: "curriculum #{opts[:tag]}")[:id]
1025
+ session = PWN::Sessions.create(title: "curriculum #{opts[:tag]}", active: true)
1026
+ sid = session[:id]
1022
1027
  # P23 — short-horizon graded tasks: cap iters so practice actually
1023
1028
  # teaches finish-under-N instead of letting DEFAULT_MAX_ITERS mask it.
1024
1029
  prompt = opts[:prompt].to_s
@@ -1058,6 +1063,8 @@ module PWN
1058
1063
  Reward.resolve_outcome(outcome: { source: :error, error: e.message }).merge(
1059
1064
  session_id: sid, prompt: opts[:prompt], final: final, trace: trace
1060
1065
  )
1066
+ ensure
1067
+ PWN::Sessions.deactivate(lease: session[:lease]) if session
1061
1068
  end
1062
1069
 
1063
1070
  private_class_method def self.score_branch(opts = {})
@@ -4,6 +4,7 @@ require 'json'
4
4
  require 'fileutils'
5
5
  require 'time'
6
6
  require 'digest'
7
+ require 'securerandom'
7
8
 
8
9
  module PWN
9
10
  module AI
@@ -29,6 +30,330 @@ module PWN
29
30
  DISPUTED_FILE = File.join(Dir.home, '.pwn', 'learning', 'disputed.jsonl')
30
31
  LESSONS_FILE = File.join(Dir.home, '.pwn', 'lessons.json')
31
32
  FINETUNE_DIR = File.join(Dir.home, '.pwn', 'finetune')
33
+ # Evidence is immutable; provenance is not permission to remove a session.
34
+ # All readers validate the content hash and full-snapshot contract first.
35
+ public_class_method def self.evidence_transcript(opts = {})
36
+ row = opts[:row].transform_keys(&:to_sym)
37
+ ref = row[:evidence_ref]
38
+ raise IOError, 'Missing learning evidence reference' unless ref.is_a?(Hash)
39
+
40
+ ref = ref.transform_keys(&:to_sym)
41
+ hash = ref[:sha256]
42
+ sid = row[:session_id]
43
+ raise IOError, 'Invalid learning evidence reference' unless ref[:version] == 1 && hash.is_a?(String) && hash.match?(/\A[0-9a-f]{64}\z/) && ref[:id] == hash && sid.is_a?(String) && sid.match?(PWN::Sessions::SESSION_ID)
44
+
45
+ path = File.join(File.dirname(LEARNING_FILE), 'learning', 'evidence', "#{hash}.json")
46
+ body = evidence_read(path: path)
47
+ raise IOError, 'Learning evidence checksum mismatch' unless Digest::SHA256.hexdigest(body) == hash
48
+
49
+ data = JSON.parse(body, symbolize_names: true)
50
+ rows = data[:transcript]
51
+ valid = data[:version] == 1 && data[:kind] == 'full_session_snapshot' && data[:session_id] == sid &&
52
+ rows.is_a?(Array) && !rows.empty? && rows.all?(Hash) && data[:row_count] == rows.length &&
53
+ data[:source_sha256].to_s.match?(/\A[0-9a-f]{64}\z/) && data[:redaction] == 'PWN::Redaction.redact'
54
+ raise IOError, 'Incomplete learning evidence' unless valid
55
+
56
+ rows
57
+ rescue JSON::ParserError, SystemCallError, TypeError, NoMethodError
58
+ raise IOError, 'Unreadable learning evidence'
59
+ end
60
+
61
+ # Return a verified immutable snapshot, never arbitrary provenance paths.
62
+ public_class_method def self.session_evidence(opts = {})
63
+ sid = opts[:session_id].to_s
64
+ return nil unless sid.match?(PWN::Sessions::SESSION_ID) && File.file?(LEARNING_FILE)
65
+
66
+ rows = evidence_read(path: LEARNING_FILE).lines.map { |line| JSON.parse(line, symbolize_names: true) }
67
+ refs = rows.select { |row| row.is_a?(Hash) && row[:session_id] == sid && row.key?(:evidence_ref) }
68
+ return nil if refs.empty?
69
+
70
+ # A single migration snapshots all outcomes for a session together.
71
+ # Divergent snapshots cannot be silently chosen for legacy ID-only callers.
72
+ transcripts = refs.map { |row| evidence_transcript(row: row) }.uniq
73
+ raise IOError, 'Ambiguous learning evidence snapshots' unless transcripts.length == 1
74
+
75
+ transcripts.first
76
+ rescue JSON::ParserError
77
+ raise IOError, 'Malformed learning evidence index'
78
+ rescue IOError
79
+ warn '[pwn-ai/learning] Retained evidence is unavailable or ambiguous; no transcript context was substituted. Check evidence integrity before training or evaluation.'
80
+ raise
81
+ end
82
+
83
+ # A marker alone never releases retention; changed live sources stay pinned.
84
+ public_class_method def self.evidence_complete?(opts = {})
85
+ row = opts[:row].transform_keys(&:to_sym)
86
+ transcript = evidence_transcript(row: row)
87
+ sid = row[:session_id]
88
+ path = File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl")
89
+ return true unless File.exist?(path) || File.symlink?(path)
90
+
91
+ ref = row[:evidence_ref].transform_keys(&:to_sym)
92
+ artifact = File.join(File.dirname(LEARNING_FILE), 'learning', 'evidence', "#{ref[:sha256]}.json")
93
+ data = JSON.parse(evidence_read(path: artifact))
94
+ source = evidence_read(path: path)
95
+ source_rows = source.lines.map { |line| JSON.parse(line, symbolize_names: true) }
96
+ Digest::SHA256.hexdigest(source) == data['source_sha256'] && PWN::Redaction.redact(value: source_rows) == transcript
97
+ rescue IOError, SystemCallError, JSON::ParserError, TypeError, NoMethodError
98
+ false
99
+ end
100
+
101
+ # Plan by default; no provider calls, vault reads, transcript logging or deletion.
102
+ public_class_method def self.migrate_evidence(opts = {})
103
+ dry = opts[:dry_run] != false
104
+ lock = learning_lock unless dry
105
+ original = File.exist?(LEARNING_FILE) ? evidence_read(path: LEARNING_FILE) : ''
106
+ malformed = 0
107
+ rows = original.lines.filter_map do |line|
108
+ row = JSON.parse(line, symbolize_names: true)
109
+ raise JSON::ParserError unless row.is_a?(Hash)
110
+
111
+ row
112
+ rescue JSON::ParserError
113
+ malformed += 1
114
+ nil
115
+ end
116
+
117
+ report = { dry_run: dry, rows: rows.length, malformed_rows: malformed, session_refs: 0, migratable_sessions: 0,
118
+ missing_sessions: 0, malformed_sessions: 0, malformed_refs: 0, verified_rows: 0,
119
+ estimated_source_bytes: 0, migrated_rows: 0 }
120
+ snapshots = {}
121
+ rows.each do |row|
122
+ sid = row[:session_id]
123
+ next if sid.nil? || sid == ''
124
+
125
+ report[:session_refs] += 1
126
+ unless sid.is_a?(String) && sid.match?(PWN::Sessions::SESSION_ID)
127
+ report[:malformed_refs] += 1
128
+ next
129
+ end
130
+ if row.key?(:evidence_ref)
131
+ report[evidence_complete?(row: row) ? :verified_rows : :malformed_refs] += 1
132
+ next
133
+ end
134
+ next if snapshots.key?(sid)
135
+
136
+ path = File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl")
137
+ snapshots[sid] = nil
138
+ unless File.exist?(path) || File.symlink?(path)
139
+ report[:missing_sessions] += 1
140
+ next
141
+ end
142
+ begin
143
+ body = evidence_read(path: path)
144
+ transcript = body.lines.map { |line| JSON.parse(line, symbolize_names: true) }
145
+ raise IOError, 'Malformed source transcript' if transcript.empty? || !transcript.all?(Hash)
146
+
147
+ payload = JSON.generate(version: 1, kind: 'full_session_snapshot', session_id: sid,
148
+ source_sha256: Digest::SHA256.hexdigest(body), row_count: transcript.length,
149
+ redaction: 'PWN::Redaction.redact', transcript: PWN::Redaction.redact(value: transcript))
150
+ hash = Digest::SHA256.hexdigest(payload)
151
+ snapshots[sid] = { body: payload, source: body, path: path, ref: { version: 1, id: hash, sha256: hash } }
152
+ report[:migratable_sessions] += 1
153
+ report[:estimated_source_bytes] += body.bytesize
154
+ rescue IOError, SystemCallError, JSON::ParserError
155
+ report[:malformed_sessions] += 1
156
+ end
157
+ end
158
+ index = PWN::Sessions.dependency_index
159
+ eligible = snapshots.select { |_sid, snapshot| snapshot }.keys
160
+ releasable = eligible.select do |sid|
161
+ rows.select { |row| row[:session_id] == sid }.all? { |row| !row.key?(:evidence_ref) || evidence_complete?(row: row) }
162
+ end
163
+ report[:unique_session_refs] = rows.filter_map { |row| row[:session_id] if row[:session_id].is_a?(String) && row[:session_id].match?(PWN::Sessions::SESSION_ID) }.uniq.length
164
+ report[:estimated_artifact_bytes] = snapshots.values.compact.sum { |snapshot| snapshot[:body].bytesize }
165
+ report[:backup_bytes] = original.bytesize
166
+ report[:migratable_rows] = rows.count { |row| !row.key?(:evidence_ref) && snapshots[row[:session_id]] }
167
+ report[:remaining_dependency_sessions] = index[:dependencies].count do |sid, paths|
168
+ !releasable.include?(sid) || paths.any? { |path| path != LEARNING_FILE }
169
+ end
170
+ report[:remaining_dependency_edges] = index[:dependencies].sum { |sid, paths| paths.count { |path| path != LEARNING_FILE || !releasable.include?(sid) } }
171
+ report[:remaining_existing_dependency_sessions] = index[:dependencies].count do |sid, paths|
172
+ File.file?(File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl")) &&
173
+ (!releasable.include?(sid) || paths.any? { |path| path != LEARNING_FILE })
174
+ end
175
+ report[:already_missing_dependency_sessions] = index[:dependencies].count { |sid, _paths| !File.file?(File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl")) }
176
+ report[:dependency_errors] = index[:errors].length
177
+ return report.merge(aborted: true) if malformed.positive?
178
+ return report if dry || eligible.empty?
179
+
180
+ evidence_commit(rows: rows, original: original, snapshots: snapshots, report: report)
181
+ ensure
182
+ learning_unlock(lock: lock) if lock
183
+ end
184
+
185
+ private_class_method def self.evidence_read(opts = {})
186
+ path = opts[:path]
187
+ raise IOError, 'Unsafe evidence path' unless File.file?(path) && File.realpath(path) == File.expand_path(path)
188
+
189
+ File.binread(path)
190
+ end
191
+
192
+ # Copy-on-write PRM metadata; never rewrite supporting actions/results.
193
+ public_class_method def self.annotate_evidence(opts = {})
194
+ sid = opts[:session_id].to_s
195
+ rewards = opts[:rewards]
196
+ raise ArgumentError, 'Invalid evidence annotation' unless sid.match?(PWN::Sessions::SESSION_ID) && rewards.is_a?(Array) && rewards.all? { |reward| reward.nil? || (reward.is_a?(Integer) && [-1, 0, 1].include?(reward)) }
197
+
198
+ lock = learning_lock
199
+ original = evidence_read(path: LEARNING_FILE)
200
+ rows = original.lines.map { |line| JSON.parse(line, symbolize_names: true) }
201
+ row = rows.find { |entry| entry[:session_id] == sid && entry[:evidence_ref] }
202
+ return { annotated: false } unless row
203
+
204
+ transcript = session_evidence(session_id: sid)
205
+ prior = row[:evidence_ref][:sha256]
206
+ artifact = File.join(File.dirname(LEARNING_FILE), 'learning', 'evidence', "#{prior}.json")
207
+ data = JSON.parse(evidence_read(path: artifact), symbolize_names: true)
208
+ index = -1
209
+ transcript.each do |entry|
210
+ next unless entry[:role].to_s == 'tool'
211
+
212
+ index += 1
213
+ entry[:step_reward] = rewards[index] unless rewards[index].nil?
214
+ end
215
+ return { annotated: false, unchanged: true } if transcript == data[:transcript]
216
+
217
+ body = JSON.generate(data.merge(transcript: transcript, derived_from: prior, annotation: 'step_reward'))
218
+ hash = Digest::SHA256.hexdigest(body)
219
+ updated = rows.map { |entry| entry[:session_id] == sid ? entry.except(:evidence_ref) : entry }
220
+ snapshot = { body: body, source: nil, path: File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl"),
221
+ ref: { version: 1, id: hash, sha256: hash } }
222
+ evidence_commit(rows: updated, original: original, snapshots: { sid => snapshot }, report: { migrated_rows: 0 }).merge(annotated: true)
223
+ ensure
224
+ learning_unlock(lock: lock) if lock
225
+ end
226
+
227
+ # Roll back only an unchanged migration while every original source exists.
228
+ public_class_method def self.rollback_evidence(opts = {})
229
+ id = opts[:migration].to_s
230
+ raise ArgumentError, 'Expected migration SHA-256 ID' unless id.match?(/\A[0-9a-f]{64}\z/)
231
+
232
+ lock = learning_lock
233
+ root = File.join(File.dirname(LEARNING_FILE), 'learning', 'migrations')
234
+ path = File.join(root, "#{id}.json")
235
+ record = JSON.parse(evidence_read(path: path), symbolize_names: true)
236
+ backup = evidence_read(path: File.join(root, "#{id}.backup.jsonl"))
237
+ raise IOError, 'Invalid migration backup' unless record[:before_sha256] == id && Digest::SHA256.hexdigest(backup) == id
238
+
239
+ current = evidence_read(path: LEARNING_FILE)
240
+ return { restored: true, unchanged: true } if current == backup
241
+ raise IOError, 'Learning changed since migration; refusing rollback' unless Digest::SHA256.hexdigest(current) == record[:after_sha256]
242
+
243
+ current_rows = current.lines.map { |line| JSON.parse(line, symbolize_names: true) }
244
+ backup.lines.each do |line|
245
+ previous = JSON.parse(line, symbolize_names: true)
246
+ if previous[:evidence_ref]
247
+ evidence_transcript(row: previous)
248
+ next
249
+ end
250
+ sid = previous[:session_id]
251
+ row = current_rows.find { |entry| entry[:session_id] == sid && entry[:evidence_ref] }
252
+ next unless row
253
+
254
+ raise IOError, 'Restore source sessions before rollback' unless evidence_complete?(row: row) && File.file?(File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl"))
255
+ end
256
+ evidence_write(path: LEARNING_FILE, body: backup)
257
+ evidence_write(path: path, body: JSON.generate(record.merge(state: 'rolled_back')))
258
+ { restored: true }
259
+ ensure
260
+ learning_unlock(lock: lock) if lock
261
+ end
262
+
263
+ private_class_method def self.evidence_write(opts = {})
264
+ path = opts[:path]
265
+ body = opts[:body]
266
+ dir = File.dirname(path)
267
+ ancestor = dir
268
+ ancestor = File.dirname(ancestor) until File.exist?(ancestor) || File.symlink?(ancestor)
269
+ raise IOError, 'Unsafe evidence ancestor' unless File.directory?(ancestor) && File.realpath(ancestor) == File.expand_path(ancestor)
270
+
271
+ FileUtils.mkdir_p(dir, mode: 0o700)
272
+ raise IOError, 'Unsafe evidence directory' unless File.realpath(dir) == File.expand_path(dir)
273
+ raise IOError, 'Unsafe evidence target' if File.symlink?(path)
274
+
275
+ tmp = "#{path}.#{SecureRandom.hex(12)}.tmp"
276
+ File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
277
+ file.write(body)
278
+ file.flush
279
+ file.fsync
280
+ end
281
+ File.rename(tmp, path)
282
+ File.open(dir, &:fsync)
283
+ raise IOError, 'Evidence write verification failed' unless evidence_read(path: path) == body
284
+
285
+ path
286
+ ensure
287
+ FileUtils.rm_f(tmp) if tmp
288
+ end
289
+
290
+ private_class_method def self.evidence_commit(opts = {})
291
+ rows, original, snapshots, report = opts.values_at(:rows, :original, :snapshots, :report)
292
+ root = File.join(File.dirname(LEARNING_FILE), 'learning')
293
+ before_hash = Digest::SHA256.hexdigest(original)
294
+ backup = File.join(root, 'migrations', "#{before_hash}.backup.jsonl")
295
+ evidence_write(path: backup, body: original) unless File.exist?(backup)
296
+ raise IOError, 'Invalid migration backup' unless evidence_read(path: backup) == original
297
+
298
+ rows.each do |row|
299
+ snapshot = snapshots[row[:session_id]]
300
+ next if row.key?(:evidence_ref) || snapshot.nil?
301
+
302
+ ref = snapshot[:ref]
303
+ path = File.join(root, 'evidence', "#{ref[:sha256]}.json")
304
+ evidence_write(path: path, body: snapshot[:body]) unless File.exist?(path)
305
+ row[:evidence_ref] = ref
306
+ evidence_transcript(row: row)
307
+ report[:migrated_rows] += 1
308
+ end
309
+ updated = rows.map { |row| "#{JSON.generate(row)}\n" }.join
310
+ ledger = File.join(root, 'migrations', "#{before_hash}.json")
311
+ record = { version: 1, state: 'prepared', before_sha256: before_hash,
312
+ after_sha256: Digest::SHA256.hexdigest(updated), migrated_rows: report[:migrated_rows] }
313
+ evidence_write(path: ledger, body: JSON.generate(record))
314
+ raise IOError, 'Learning changed during migration' unless evidence_read(path: LEARNING_FILE) == original
315
+
316
+ snapshots.each_value do |snapshot|
317
+ next unless snapshot
318
+
319
+ if snapshot[:source].nil?
320
+ raise IOError, 'Session appeared during evidence annotation' if File.exist?(snapshot[:path]) || File.symlink?(snapshot[:path])
321
+
322
+ next
323
+ end
324
+
325
+ raise IOError, 'Session changed during migration' unless evidence_read(path: snapshot[:path]) == snapshot[:source]
326
+ end
327
+ evidence_write(path: LEARNING_FILE, body: updated)
328
+ evidence_write(path: ledger, body: JSON.generate(record.merge(state: 'committed')))
329
+ report.merge(ledger: ledger)
330
+ end
331
+
332
+ # Reentrant across note_outcome -> pruning; flock serializes cooperating processes.
333
+ private_class_method def self.learning_lock
334
+ key = :pwn_learning_write_lock
335
+ return nil if Thread.current[key]
336
+
337
+ root = File.dirname(LEARNING_FILE)
338
+ FileUtils.mkdir_p(root, mode: 0o700)
339
+ path = File.join(root, '.learning-write.lock')
340
+ raise IOError, 'Unsafe learning lock' if File.symlink?(path) || File.realpath(root) != File.expand_path(root)
341
+
342
+ # Ownership transfers to the caller's ensure/learning_unlock.
343
+ file = File.open(path, File::RDWR | File::CREAT, 0o600) # rubocop:disable Style/FileOpen
344
+ file.flock(File::LOCK_EX)
345
+ Thread.current[key] = file
346
+ file
347
+ end
348
+
349
+ private_class_method def self.learning_unlock(opts = {})
350
+ file = opts[:lock]
351
+ return unless file
352
+
353
+ Thread.current[:pwn_learning_write_lock] = nil
354
+ file.close
355
+ end
356
+
32
357
  # P0 — post-answer introspect must not train "stop early" while
33
358
  # spending the iteration budget after the final. Soft cap skips
34
359
  # expensive stages (tool critic, PRM-LLM, reflect, extrospect);
@@ -91,6 +416,7 @@ module PWN
91
416
  # )
92
417
 
93
418
  public_class_method def self.note_outcome(opts = {})
419
+ lock = learning_lock
94
420
  task = opts[:task].to_s
95
421
  # 4.1 — allow success: 'soft' (HER) distinct from true/false
96
422
  raw_ok = opts[:success]
@@ -169,6 +495,8 @@ module PWN
169
495
  Curriculum.calibrate(predicted: pred, actual: decision ? decision[:training_score] : opts[:score], engine: eng)
170
496
  end
171
497
  entry
498
+ ensure
499
+ learning_unlock(lock: lock) if lock
172
500
  end
173
501
 
174
502
  public_class_method def self.consistency_check(opts = {})
@@ -843,6 +1171,7 @@ module PWN
843
1171
  end
844
1172
 
845
1173
  public_class_method def self.flip_last_outcome(opts = {})
1174
+ lock = learning_lock
846
1175
  return { flipped: false } unless File.exist?(LEARNING_FILE)
847
1176
 
848
1177
  lines = File.readlines(LEARNING_FILE)
@@ -869,6 +1198,8 @@ module PWN
869
1198
  { flipped: true, id: last[:id], rejected: last[:details].to_s[0, 2_000] }
870
1199
  rescue StandardError
871
1200
  { flipped: false }
1201
+ ensure
1202
+ learning_unlock(lock: lock) if lock
872
1203
  end
873
1204
 
874
1205
  # Supported Method Parameters::
@@ -959,8 +1290,11 @@ module PWN
959
1290
  # PWN::AI::Agent::Learning.reset
960
1291
 
961
1292
  public_class_method def self.reset
1293
+ lock = learning_lock
962
1294
  FileUtils.rm_f(LEARNING_FILE)
963
1295
  { cleared: true }
1296
+ ensure
1297
+ learning_unlock(lock: lock) if lock
964
1298
  end
965
1299
 
966
1300
  # -------------------------------------------------------------
@@ -1168,6 +1502,7 @@ module PWN
1168
1502
  # One-shot / on-load repair: rewrite tags+details where verdict label
1169
1503
  # disagrees with score (solved @ 0.3 etc.). Safe to call repeatedly.
1170
1504
  public_class_method def self.reconcile_verdict_tags!(opts = {})
1505
+ lock = learning_lock unless opts[:dry_run]
1171
1506
  return { repaired: 0 } unless File.exist?(LEARNING_FILE)
1172
1507
 
1173
1508
  dry = opts[:dry_run] ? true : false
@@ -1206,6 +1541,8 @@ module PWN
1206
1541
  { repaired: repaired, dry_run: dry }
1207
1542
  rescue StandardError => e
1208
1543
  { repaired: 0, error: "#{e.class}: #{e.message}" }
1544
+ ensure
1545
+ learning_unlock(lock: lock) if lock
1209
1546
  end
1210
1547
 
1211
1548
  private_class_method def self.auto_introspect_enabled?
@@ -1599,6 +1936,7 @@ module PWN
1599
1936
  # keeping best score. Truncate details. Never sacrifices exemplar pool.
1600
1937
 
1601
1938
  public_class_method def self.prune_outcomes!(opts = {})
1939
+ lock = learning_lock unless opts[:dry_run]
1602
1940
  dry = opts[:dry_run] ? true : false
1603
1941
  max_rows = (opts[:max_rows] || MAX_OUTCOME_ROWS).to_i
1604
1942
  retain_days = (opts[:retain_days] || OUTCOME_RETAIN_DAYS).to_f
@@ -1722,6 +2060,8 @@ module PWN
1722
2060
  end,
1723
2061
  dry_run: dry
1724
2062
  }
2063
+ ensure
2064
+ learning_unlock(lock: lock) if lock
1725
2065
  end
1726
2066
 
1727
2067
  # Memory lean + outcome prune.
@@ -1940,6 +2280,7 @@ module PWN
1940
2280
  end
1941
2281
 
1942
2282
  public_class_method def self.requeue_conflicted(opts = {})
2283
+ lock = learning_lock unless opts[:dry_run]
1943
2284
  dry = opts[:dry_run] ? true : false
1944
2285
  rows = list_conflicted(limit: 10_000)
1945
2286
  return { rescored: 0, dry_run: dry } if rows.empty? || dry
@@ -1974,6 +2315,8 @@ module PWN
1974
2315
  n += 1
1975
2316
  end
1976
2317
  { rescored: n, dry_run: false }
2318
+ ensure
2319
+ learning_unlock(lock: lock) if lock
1977
2320
  end
1978
2321
 
1979
2322
  public_class_method def self.compact!(opts = {})
@@ -1998,6 +2341,28 @@ module PWN
1998
2341
  # Display Usage for this Module
1999
2342
 
2000
2343
  public_class_method def self.help
2344
+ puts "
2345
+ # Plan a full-snapshot evidence migration, or explicitly apply it.
2346
+ #{self}.migrate_evidence(dry_run: 'optional - Boolean; defaults true, false writes evidence and references')
2347
+
2348
+ # Restore an unchanged learning ledger before any source-session cleanup.
2349
+ #{self}.rollback_evidence(migration: 'required - SHA-256 migration ID from the ledger filename')
2350
+
2351
+ # Persist derived PRM rewards without rewriting immutable supporting content.
2352
+ #{self}.annotate_evidence(
2353
+ session_id: 'required - canonical source session ID with retained evidence and no live transcript',
2354
+ rewards: 'required - Array of integer -1, 0, 1 or nil per tool row'
2355
+ )
2356
+
2357
+ # Read and verify the immutable evidence supporting one outcome.
2358
+ #{self}.evidence_transcript(row: 'required - outcome Hash including session_id and evidence_ref')
2359
+
2360
+ # Resolve a legacy session ID to an unambiguous verified snapshot.
2361
+ #{self}.session_evidence(session_id: 'required - canonical source session ID')
2362
+
2363
+ # Check complete evidence and unchanged source before releasing a pin.
2364
+ #{self}.evidence_complete?(row: 'required - outcome Hash including session_id and evidence_ref')
2365
+ "
2001
2366
  puts "USAGE:
2002
2367
  # Run note outcome and return its result
2003
2368
  #{self}.note_outcome(
@@ -3105,6 +3105,8 @@ module PWN
3105
3105
 
3106
3106
  public_class_method def self.run(opts = {})
3107
3107
  options = opts.merge(request: opts[:request])
3108
+ sid = opts[:session_id].to_s
3109
+ lease = PWN::Sessions.activate(session_id: sid) if !sid.empty? && File.file?(File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl"))
3108
3110
  original_system = opts[:system_role_content]
3109
3111
  begin
3110
3112
  run_turn(options)
@@ -3121,6 +3123,8 @@ module PWN
3121
3123
  options.delete(:verification_contract)
3122
3124
  retry
3123
3125
  end
3126
+ ensure
3127
+ PWN::Sessions.deactivate(lease: lease)
3124
3128
  end
3125
3129
 
3126
3130
  private_class_method def self.update_steered_open_goal!(opts = {})
@@ -1647,8 +1647,17 @@ module PWN
1647
1647
  return unless sid && defined?(PWN::Sessions)
1648
1648
 
1649
1649
  path = File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl")
1650
- return unless File.exist?(path)
1650
+ unless File.exist?(path)
1651
+ result = Learning.annotate_evidence(session_id: sid, rewards: rewards)
1652
+ if result[:annotated]
1653
+ PWN::Sessions.load(session_id: sid).select { |row| row[:role].to_s == 'tool' }.each_with_index do |row, index|
1654
+ fold_step_reward_to_metrics(content: row[:content], reward: rewards[index]) unless rewards[index].nil?
1655
+ end
1656
+ end
1657
+ return result
1658
+ end
1651
1659
 
1660
+ lease = PWN::Sessions.activate(session_id: sid)
1652
1661
  lines = File.readlines(path)
1653
1662
  ti = -1
1654
1663
  lines.map! do |l|
@@ -1668,6 +1677,8 @@ module PWN
1668
1677
  File.write(path, lines.join)
1669
1678
  rescue StandardError
1670
1679
  nil
1680
+ ensure
1681
+ PWN::Sessions.deactivate(lease: lease)
1671
1682
  end
1672
1683
 
1673
1684
  # Extract tool name from "name → …" session content and record PRM.
@@ -253,7 +253,8 @@ module PWN
253
253
  bus_append(swarm_id: sid, from: opts[:from] || caller_label,
254
254
  to: name, content: opts[:request].to_s)
255
255
 
256
- session_id = persona_session(swarm_id: sid, name: name)
256
+ session_leases = []
257
+ session_id = persona_session(swarm_id: sid, name: name, leases: session_leases)
257
258
  sys = build_persona_prompt(name: name, persona: persona,
258
259
  swarm_id: sid, session_id: session_id)
259
260
  sys = "#{sys}\n#{opts[:handoff]}" if opts[:handoff]
@@ -285,6 +286,7 @@ module PWN
285
286
  (Thread.current[:pwn_swarm_honesty] ||= []) << honesty.merge(name: name) unless empty_tools
286
287
  { ok: true, swarm_id: sid, name: name, session_id: session_id, reply: reply, inbox: inbox, honesty: honesty }
287
288
  ensure
289
+ Array(session_leases).each { |lease| PWN::Sessions.deactivate(lease: lease) }
288
290
  Thread.current[:pwn_swarm_depth] = depth
289
291
  if empty_tools
290
292
  Thread.current[:pwn_swarm_id] = prior_id
@@ -410,12 +412,17 @@ module PWN
410
412
  name = opts[:name].to_s
411
413
  map_path = File.join(SWARM_ROOT, sid, 'personas.json')
412
414
  map = File.exist?(map_path) ? JSON.parse(File.read(map_path)) : {}
413
- return map[name] if map[name]
415
+ if map[name]
416
+ opts[:leases] << PWN::Sessions.activate(session_id: map[name]) if opts[:leases]
417
+ return map[name]
418
+ end
414
419
 
415
420
  sess = PWN::Sessions.create(
416
421
  title: "swarm:#{sid} persona:#{name}",
417
- source: 'pwn-ai-swarm'
422
+ source: 'pwn-ai-swarm',
423
+ active: !opts[:leases].nil?
418
424
  )
425
+ opts[:leases] << sess[:lease] if opts[:leases]
419
426
  map[name] = sess[:id]
420
427
  File.write(map_path, JSON.pretty_generate(map))
421
428
  sess[:id]
@@ -226,17 +226,34 @@ PWN::AI::Agent::Registry.register(
226
226
  name: 'learning_gc_stores',
227
227
  description: 'Lean-keep ~/.pwn RL stores (memory, learning.jsonl, mistakes.json, ' \
228
228
  'sessions/) without dropping gold outcomes, open mistakes, prefs/SOPs, ' \
229
- 'or reference-pinned session transcripts. dry_run:true plans only.',
229
+ 'or reference-pinned session transcripts. dry_run:true plans only. ' \
230
+ 'operation:evidence_plan audits immutable evidence migration; evidence_apply writes it, ' \
231
+ 'evidence_rollback restores an unchanged ledger before session cleanup. None deletes sessions.',
230
232
  parameters: {
231
233
  type: 'object',
232
234
  properties: {
233
- dry_run: { type: 'boolean', default: false, description: 'Report only; do not rewrite.' }
235
+ dry_run: { type: 'boolean', default: false, description: 'Report only; do not rewrite.' },
236
+ operation: { type: 'string', enum: %w[gc evidence_plan evidence_apply evidence_rollback], default: 'gc' },
237
+ migration: { type: 'string', description: 'SHA-256 migration ID for evidence_rollback.' }
234
238
  },
235
239
  required: []
236
240
  }
237
241
  },
238
242
  check: -> { defined?(PWN::AI::Agent::Learning) && PWN::AI::Agent::Learning.respond_to?(:gc_stores!) },
239
243
  handler: lambda { |args|
240
- PWN::AI::Agent::Learning.gc_stores!(dry_run: args[:dry_run] ? true : false)
244
+ case args[:operation].to_s
245
+ when 'evidence_plan'
246
+ next PWN::AI::Agent::Learning.migrate_evidence(dry_run: true)
247
+ when 'evidence_apply'
248
+ next PWN::AI::Agent::Learning.migrate_evidence(dry_run: args[:dry_run] == true)
249
+ when 'evidence_rollback'
250
+ raise ArgumentError, 'Use evidence_plan for read-only inspection' if args[:dry_run]
251
+
252
+ next PWN::AI::Agent::Learning.rollback_evidence(migration: args[:migration])
253
+ when '', 'gc'
254
+ next PWN::AI::Agent::Learning.gc_stores!(dry_run: args[:dry_run] ? true : false)
255
+ else
256
+ raise ArgumentError, 'Unknown learning maintenance operation'
257
+ end
241
258
  }
242
259
  )