@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21
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/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +45 -7
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +908 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -3
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +41 -34
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +21 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/config.yml +3 -3
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- package/templates/roadmap.md +13 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
# HookReplay (intent 331a, T1; promoted to a production lib in 331e) - streams
|
|
9
|
+
# text through a MessageDisplay launcher the way Claude Code streams an
|
|
10
|
+
# assistant reply, chunk by chunk, and returns every chunk's raw stdout.
|
|
11
|
+
#
|
|
12
|
+
# 331a's test/support/hook_replay.rb held this as test-only code; 331e's
|
|
13
|
+
# doctor `display_hook_paints` check (scripts/doctor.rb) needs the exact same
|
|
14
|
+
# mechanics to replay the INSTALLED launcher for real, so the logic lives
|
|
15
|
+
# here and test/support/hook_replay.rb now delegates to it (a require, not a
|
|
16
|
+
# duplicate). Never require this from scripts/lib/doctor_core.rb: that file
|
|
17
|
+
# is the SessionStart boot path (test/doctor_core_split_test.rb T2 pins its
|
|
18
|
+
# exact require set), and the paint check that needs this lib runs only from
|
|
19
|
+
# the full scripts/doctor.rb.
|
|
20
|
+
module HookReplay
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Streams `text` through `hook_path` in fixed-size chunks. `session_id`/
|
|
24
|
+
# `message_id` default to fixed values since nothing about a replay depends
|
|
25
|
+
# on the ambient session at all.
|
|
26
|
+
#
|
|
27
|
+
# `env` (intent 331e): extra child-process environment, merged over the
|
|
28
|
+
# PLASTIC_TMP entry every call already sets (a caller's own key wins). A
|
|
29
|
+
# `nil` value unsets that variable in the child (Process.spawn's own
|
|
30
|
+
# convention) is how a caller forces NO_COLOR off regardless of the ambient
|
|
31
|
+
# environment. Default `{}` keeps every existing caller's behavior
|
|
32
|
+
# unchanged: this is an extension, not a fork.
|
|
33
|
+
#
|
|
34
|
+
# `timeout` (intent 331e): when given, bounds EACH chunk's spawn to that
|
|
35
|
+
# many seconds. A bare `Timeout.timeout` around `Open3.capture3` does not
|
|
36
|
+
# reliably bound a genuinely hanging child: capture3's own wait still
|
|
37
|
+
# blocks on Process.waitpid for the child regardless of the raised
|
|
38
|
+
# Timeout::Error (the same gotcha scripts/hook-record works around), so a
|
|
39
|
+
# timeout here spawns directly and kills the child on expiry instead.
|
|
40
|
+
# Default `nil` keeps every existing caller on the original unbounded
|
|
41
|
+
# Open3.capture3 path.
|
|
42
|
+
def replay(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay", message_id: "replay",
|
|
43
|
+
env: {}, timeout: nil)
|
|
44
|
+
chunks = text.scan(/.{1,#{chunk}}/m)
|
|
45
|
+
chunks = [""] if chunks.empty?
|
|
46
|
+
full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
|
|
47
|
+
|
|
48
|
+
chunks.each_with_index.map do |delta, i|
|
|
49
|
+
payload = {
|
|
50
|
+
"session_id" => session_id, "message_id" => message_id, "index" => i,
|
|
51
|
+
"final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
|
|
52
|
+
"hook_event_name" => "MessageDisplay",
|
|
53
|
+
}
|
|
54
|
+
out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, timeout)
|
|
55
|
+
{ index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# replay_concurrent (intent 331a1) - streams `text` through `hook_path` the
|
|
60
|
+
# way `replay` does, but fires every chunk in its OWN thread, staggered by
|
|
61
|
+
# `gap_ms` (plus up to half a gap of jitter when `jitter` is true) rather
|
|
62
|
+
# than run sequentially. This is what reproduces the decision-race defect
|
|
63
|
+
# 331a1 fixes: Claude Code fires the per-chunk hook processes CONCURRENTLY
|
|
64
|
+
# in production, and `replay`'s strictly-sequential default never puts two
|
|
65
|
+
# chunks in flight at once, so it could never have reproduced the race in
|
|
66
|
+
# the first place.
|
|
67
|
+
#
|
|
68
|
+
# `gap_ms: 5` plus jitter is the default on purpose, not "fire everything
|
|
69
|
+
# at once": firing all 335 chunks of the live session capture with no
|
|
70
|
+
# stagger at all takes about 8 s of wall clock on this 8-core machine,
|
|
71
|
+
# because EACH chunk boots its own real Ruby process and the completions
|
|
72
|
+
# cluster at the tail once every core is saturated - a load real streaming
|
|
73
|
+
# never produces (a real stream delivers a chunk every few tens of
|
|
74
|
+
# milliseconds, one at a time). One Ruby process per streamed chunk is the
|
|
75
|
+
# actual throughput ceiling here, not something this method works around.
|
|
76
|
+
#
|
|
77
|
+
# `replay`'s own signature and sequential default are UNCHANGED by this
|
|
78
|
+
# method's existence (`scripts/doctor.rb:2571` calls `replay` directly and
|
|
79
|
+
# must keep working exactly as it does today) - this is a sibling method
|
|
80
|
+
# in the same module, never a replacement.
|
|
81
|
+
#
|
|
82
|
+
# Returns the SAME result shape `replay` returns (one Hash per chunk, keys
|
|
83
|
+
# index/exitstatus/stdout/stderr/final), ordered by index regardless of
|
|
84
|
+
# the order the threads actually finish in.
|
|
85
|
+
def replay_concurrent(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay",
|
|
86
|
+
message_id: "replay", env: {}, gap_ms: 5, jitter: true)
|
|
87
|
+
chunks = text.scan(/.{1,#{chunk}}/m)
|
|
88
|
+
chunks = [""] if chunks.empty?
|
|
89
|
+
full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
|
|
90
|
+
gap = gap_ms / 1000.0
|
|
91
|
+
|
|
92
|
+
results = Array.new(chunks.length)
|
|
93
|
+
threads = chunks.each_with_index.map do |delta, i|
|
|
94
|
+
payload = {
|
|
95
|
+
"session_id" => session_id, "message_id" => message_id, "index" => i,
|
|
96
|
+
"final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
|
|
97
|
+
"hook_event_name" => "MessageDisplay",
|
|
98
|
+
}
|
|
99
|
+
Thread.new do
|
|
100
|
+
begin
|
|
101
|
+
delay = i * gap
|
|
102
|
+
delay += (rand * gap / 2.0) if jitter
|
|
103
|
+
sleep(delay)
|
|
104
|
+
out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
|
|
105
|
+
results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
# A raise inside a thread body is invisible until join, and an
|
|
108
|
+
# unrescued one aborts `threads.each(&:join)` at the first dead
|
|
109
|
+
# thread: every later thread is then never joined and outlives the
|
|
110
|
+
# call, racing whatever the caller does next (typically removing
|
|
111
|
+
# the very tmp root those threads are still writing under). Report
|
|
112
|
+
# the failure as this chunk's own result instead, so the array is
|
|
113
|
+
# always complete, every thread is always joined, and a replay
|
|
114
|
+
# tells its caller what went wrong rather than throwing at it.
|
|
115
|
+
results[i] = { index: i, exitstatus: nil, stdout: "", stderr: e.message,
|
|
116
|
+
final: payload["final"] }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
threads.each { |thread| thread.join }
|
|
121
|
+
results
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Indices of the chunks that reached the terminal as raw Markdown: a
|
|
125
|
+
# non-final chunk that emitted nothing at all, after the engaging chunk.
|
|
126
|
+
# A chunk "passed through" when its stdout is empty; a chunk was
|
|
127
|
+
# "blanked" (correctly buffered, not shown raw) when its stdout contains
|
|
128
|
+
# `"displayContent":""`. Only chunks with an index greater than the
|
|
129
|
+
# engaging chunk's own index count - the engaging chunk is the first one
|
|
130
|
+
# (at or after `start_index`) whose stdout is non-empty, and chunks
|
|
131
|
+
# before it already reached the terminal live, verbatim, through the
|
|
132
|
+
# ordinary passthrough path (they were never candidates for buffering at
|
|
133
|
+
# all, so an empty stdout from one of them is not this defect).
|
|
134
|
+
def passthrough_indices(outs, start_index: 0)
|
|
135
|
+
engaging = outs.find { |o| o[:index] >= start_index && !o[:stdout].to_s.empty? }
|
|
136
|
+
return [] unless engaging
|
|
137
|
+
|
|
138
|
+
outs.select { |o| o[:index] > engaging[:index] && o[:final] != true && o[:stdout].to_s.empty? }
|
|
139
|
+
.map { |o| o[:index] }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def run_one(hook_path, payload, full_env, tmp_root, timeout)
|
|
143
|
+
return capture(hook_path, payload, full_env) unless timeout
|
|
144
|
+
|
|
145
|
+
run_bounded(hook_path, payload, full_env, tmp_root, timeout)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def capture(hook_path, payload, full_env)
|
|
149
|
+
out, err, status = Open3.capture3(full_env, hook_path, stdin_data: JSON.generate(payload))
|
|
150
|
+
[out, err, status.exitstatus]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Spawn directly (never Open3.capture3) so a timeout can actually kill the
|
|
154
|
+
# child, with stdin/stdout/stderr routed through scratch files under the
|
|
155
|
+
# caller's own tmp_root, and never pipes, so a stalled or oversized write can
|
|
156
|
+
# never deadlock the read side, and never anywhere outside tmp_root, so a
|
|
157
|
+
# bounded replay carries the same "writes only under the injected tmp
|
|
158
|
+
# root" guarantee as the unbounded path.
|
|
159
|
+
def run_bounded(hook_path, payload, full_env, tmp_root, timeout)
|
|
160
|
+
token = "#{Process.pid}-#{(Time.now.to_f * 1_000_000).to_i}-#{rand(1_000_000)}"
|
|
161
|
+
in_path = File.join(tmp_root, ".hook-replay-in-#{token}")
|
|
162
|
+
out_path = File.join(tmp_root, ".hook-replay-out-#{token}")
|
|
163
|
+
err_path = File.join(tmp_root, ".hook-replay-err-#{token}")
|
|
164
|
+
File.write(in_path, JSON.generate(payload))
|
|
165
|
+
|
|
166
|
+
pid = Process.spawn(full_env, hook_path, in: in_path, out: out_path, err: err_path)
|
|
167
|
+
exitstatus =
|
|
168
|
+
begin
|
|
169
|
+
Timeout.timeout(timeout) { Process.wait(pid) }
|
|
170
|
+
$?.exitstatus
|
|
171
|
+
rescue Timeout::Error
|
|
172
|
+
kill_and_reap(pid)
|
|
173
|
+
nil # nil exitstatus is the caller's signal that this chunk timed out
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
out = File.exist?(out_path) ? File.read(out_path) : ""
|
|
177
|
+
err = File.exist?(err_path) ? File.read(err_path) : ""
|
|
178
|
+
[out, err, exitstatus]
|
|
179
|
+
ensure
|
|
180
|
+
[in_path, out_path, err_path].each { |p| File.delete(p) if p && File.exist?(p) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def kill_and_reap(pid)
|
|
184
|
+
Process.kill("KILL", pid)
|
|
185
|
+
rescue StandardError
|
|
186
|
+
nil
|
|
187
|
+
ensure
|
|
188
|
+
begin
|
|
189
|
+
Process.wait(pid)
|
|
190
|
+
rescue StandardError
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The final chunk's parsed displayContent, or nil when it emitted nothing
|
|
196
|
+
# (no envelope at all: the message never engaged).
|
|
197
|
+
def final_display_content(outs)
|
|
198
|
+
final = outs.last
|
|
199
|
+
return nil if final[:stdout].to_s.empty?
|
|
200
|
+
|
|
201
|
+
JSON.parse(final[:stdout]).dig("hookSpecificOutput", "displayContent")
|
|
202
|
+
rescue JSON::ParserError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# True when any chunk's spawn hit its timeout (run_bounded's nil-exitstatus
|
|
207
|
+
# signal). A replay made with no `timeout:` never reports true.
|
|
208
|
+
def timed_out?(outs)
|
|
209
|
+
outs.any? { |o| o[:exitstatus].nil? }
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "doctor_exclusions"
|
|
5
|
+
require_relative "graph_file"
|
|
6
|
+
require_relative "atomic_write"
|
|
7
|
+
|
|
8
|
+
# IndexProjection (intent 337, n5): computes every intent's status from its
|
|
9
|
+
# own savepoint.md ledger, reads the status INDEX.md currently claims, and
|
|
10
|
+
# reports the drift between them. The ledger wins WHERE THE LEDGER SPEAKS
|
|
11
|
+
# (row 5.1/5.2): a REAL terminal line (Done delivered/abandoned, or a
|
|
12
|
+
# classifiable Done detail) beats a stale INDEX section. An intent whose
|
|
13
|
+
# ledger is silent (no terminal line) or absent (no savepoint.md at all)
|
|
14
|
+
# keeps the status INDEX already carries (row 5.13, folded at the
|
|
15
|
+
# 2026-09-10 plan review): 63 of 451 intents in the plastic store have no
|
|
16
|
+
# savepoint.md and 59 more never reach a Done line, and a literal reading
|
|
17
|
+
# would demote all of them. This module computes and compares only; it
|
|
18
|
+
# writes nothing (row 5.11) and reads no clock or environment variable.
|
|
19
|
+
module IndexProjection
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
INDEX_SECTIONS = %w[Active Future Completed Abandoned].freeze
|
|
23
|
+
TERMINAL_STATUSES = %w[Completed Abandoned].freeze
|
|
24
|
+
EXCLUSION_RULES = %w[savepoint_operational backfilled_complete].freeze
|
|
25
|
+
|
|
26
|
+
# `store_path` holds the intent directories (a project's own `store/`, or
|
|
27
|
+
# the global `~/.plastic/store`). `index_path` defaults to a sibling
|
|
28
|
+
# `INDEX.md` inside `store_path` (matching every fixture in this test
|
|
29
|
+
# file), but the real Plastic layout keeps INDEX.md one level ABOVE
|
|
30
|
+
# `store/` - callers there (doctor.rb, the CLI) pass it explicitly.
|
|
31
|
+
def analyze(store_path, index_path: nil)
|
|
32
|
+
index_path ||= File.join(store_path, "INDEX.md")
|
|
33
|
+
index_map = read_index(index_path)
|
|
34
|
+
dir_ids = store_intent_ids(store_path)
|
|
35
|
+
|
|
36
|
+
excluded = excluded_ids(index_path)
|
|
37
|
+
|
|
38
|
+
drift = index_map.filter_map do |id, index_status|
|
|
39
|
+
next if index_status == "Future" # 5.5: Future has no ledger counterpart
|
|
40
|
+
next if excluded.include?(id) # 5.12
|
|
41
|
+
|
|
42
|
+
ledger_status = ledger_status_for(store_path, id)
|
|
43
|
+
next unless TERMINAL_STATUSES.include?(ledger_status) # 5.3/5.13
|
|
44
|
+
next if ledger_status == index_status
|
|
45
|
+
|
|
46
|
+
{ id: id, index_status: index_status, ledger_status: ledger_status }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
index_only = (index_map.keys - dir_ids).reject { |id| excluded.include?(id) }
|
|
50
|
+
.map { |id| { id: id, index_status: index_map[id] } }
|
|
51
|
+
directory_only = (dir_ids - index_map.keys).reject { |id| excluded.include?(id) }
|
|
52
|
+
.map { |id| { id: id } }
|
|
53
|
+
|
|
54
|
+
{ ok: true, drift: drift, index_only: index_only, directory_only: directory_only, errors: [] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Render the four status sections (## Active, ## Future, ## Completed,
|
|
58
|
+
# ## Abandoned) from the projection and write through AtomicWrite. Only
|
|
59
|
+
# entries `analyze` actually reported as `drift` ever move - an entry
|
|
60
|
+
# whose ledger is silent or absent never appears in `drift` (row 5.13),
|
|
61
|
+
# so --write can never demote it (row 6.14). Every other line, including
|
|
62
|
+
# ## Clusters and ## Relocated (or any other section), is untouched
|
|
63
|
+
# (row 6.13): only the four named headings are ever replaced.
|
|
64
|
+
def write(store_path, index_path:, renamer: File.method(:rename))
|
|
65
|
+
return { ok: false, written: false, error: "no INDEX.md at #{index_path}" } unless File.exist?(index_path)
|
|
66
|
+
|
|
67
|
+
analysis = analyze(store_path, index_path: index_path)
|
|
68
|
+
text = read_utf8(index_path)
|
|
69
|
+
|
|
70
|
+
original_lines = {}
|
|
71
|
+
INDEX_SECTIONS.each do |heading|
|
|
72
|
+
section_body(text, heading).each_line do |line|
|
|
73
|
+
next unless line.strip.start_with?("- [")
|
|
74
|
+
|
|
75
|
+
m = line.strip.match(/\A-\s*\[(\S+)\s/)
|
|
76
|
+
original_lines[m[1]] = line if m
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
moves = analysis[:drift].each_with_object({}) { |r, h| h[r[:id]] = r[:ledger_status] }
|
|
81
|
+
|
|
82
|
+
new_bodies = INDEX_SECTIONS.each_with_object({}) do |heading, h|
|
|
83
|
+
kept = section_body(text, heading).each_line.reject do |line|
|
|
84
|
+
stripped = line.strip
|
|
85
|
+
next false unless stripped.start_with?("- [")
|
|
86
|
+
|
|
87
|
+
m = stripped.match(/\A-\s*\[(\S+)\s/)
|
|
88
|
+
m && moves.key?(m[1])
|
|
89
|
+
end
|
|
90
|
+
arriving = moves.select { |_id, target| target == heading }.keys
|
|
91
|
+
h[heading] = (kept + arriving.filter_map { |id| original_lines[id] }).join
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
content = text
|
|
95
|
+
INDEX_SECTIONS.each do |heading|
|
|
96
|
+
content = GraphFile.replace_or_append_section(content, "## #{heading}", new_bodies[heading].rstrip + "\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
AtomicWrite.write(index_path, content, renamer: renamer)
|
|
100
|
+
{ ok: true, written: true, moved: analysis[:drift], error: nil }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# --- INDEX -------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def read_index(index_path)
|
|
106
|
+
map = {}
|
|
107
|
+
return map unless File.exist?(index_path)
|
|
108
|
+
|
|
109
|
+
text = read_utf8(index_path)
|
|
110
|
+
INDEX_SECTIONS.each do |heading|
|
|
111
|
+
section_body(text, heading).each_line do |line|
|
|
112
|
+
stripped = line.strip
|
|
113
|
+
next unless stripped.start_with?("- [")
|
|
114
|
+
|
|
115
|
+
m = stripped.match(/\A-\s*\[(\S+)\s/)
|
|
116
|
+
map[m[1]] = heading if m
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
map
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def section_body(text, heading)
|
|
123
|
+
m = text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)
|
|
124
|
+
m ? m[1] : ""
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# --- store directories ---------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def store_intent_ids(store_path)
|
|
130
|
+
return [] unless Dir.exist?(store_path)
|
|
131
|
+
|
|
132
|
+
Dir.entries(store_path).select { |e| e.include?("--") && File.directory?(File.join(store_path, e)) }
|
|
133
|
+
.map { |e| e.split("--", 2).first }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def intent_dir_for(store_path, id)
|
|
137
|
+
Dir.glob(File.join(store_path, "#{id}--*")).find { |p| File.directory?(p) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# --- the ledger (savepoint.md) --------------------------------------------------
|
|
141
|
+
|
|
142
|
+
# "Completed", "Abandoned", "Active" (no terminal line yet), "unknown" (no
|
|
143
|
+
# directory or no savepoint.md), or "indeterminate" (a Done line whose
|
|
144
|
+
# detail is neither delivered/merged nor abandoned, row 5.14).
|
|
145
|
+
def ledger_status_for(store_path, id)
|
|
146
|
+
dir = intent_dir_for(store_path, id)
|
|
147
|
+
return "unknown" unless dir
|
|
148
|
+
|
|
149
|
+
sp_path = File.join(dir, "savepoint.md")
|
|
150
|
+
return "unknown" unless File.exist?(sp_path)
|
|
151
|
+
|
|
152
|
+
parsed = complete_lines(read_utf8(sp_path)).filter_map { |l| parse_line(l) }
|
|
153
|
+
last_done = parsed.reverse.find { |p| p[:kind] == "Done" }
|
|
154
|
+
return "Active" unless last_done
|
|
155
|
+
|
|
156
|
+
classify_done_detail(last_done[:detail]) || "indeterminate"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Drop the trailing torn fragment: a savepoint line that never got its
|
|
160
|
+
# closing newline because the process died mid-append (row 5.6). Works
|
|
161
|
+
# whether the file ends in a newline (the dropped element is the empty
|
|
162
|
+
# string split(-1) always yields after a final "\n") or not (the dropped
|
|
163
|
+
# element is the torn fragment itself).
|
|
164
|
+
def complete_lines(content)
|
|
165
|
+
return [] if content.to_s.empty?
|
|
166
|
+
|
|
167
|
+
lines = content.split("\n", -1)
|
|
168
|
+
lines.pop
|
|
169
|
+
lines.reject { |l| l.strip.empty? }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def parse_line(line)
|
|
173
|
+
parts = line.strip.split(/\s{2,}/, 3)
|
|
174
|
+
return nil unless parts.length == 3
|
|
175
|
+
|
|
176
|
+
{ time: parts[0], kind: parts[1], detail: parts[2] }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def classify_done_detail(detail)
|
|
180
|
+
d = detail.to_s.strip
|
|
181
|
+
return "Abandoned" if d.start_with?("abandoned")
|
|
182
|
+
return "Completed" if d.start_with?("delivered") || d.start_with?("merged")
|
|
183
|
+
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# --- doctor exclusions (row 5.12) -----------------------------------------------
|
|
188
|
+
|
|
189
|
+
def excluded_ids(index_path)
|
|
190
|
+
loaded = DoctorExclusions.load(index_path)
|
|
191
|
+
EXCLUSION_RULES.flat_map { |rule| loaded[:rules][rule] || [] }.uniq
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# --- utf-8 -----------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def read_utf8(path)
|
|
197
|
+
text = File.read(path)
|
|
198
|
+
text.force_encoding(Encoding::UTF_8)
|
|
199
|
+
text.valid_encoding? ? text : text.scrub("")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -76,8 +76,25 @@ class InstallerCore
|
|
|
76
76
|
@agents = agents
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# Reads the package version from `package.json` when present (the source
|
|
80
|
+
# tree, and any dev checkout), falling back to the bare `VERSION` file the
|
|
81
|
+
# installer copies to `~/.plastic` (row B, spec 315b): an installed system
|
|
82
|
+
# carries VERSION but never package.json, so `rollback.rb` and `update.rb`
|
|
83
|
+
# constructing an InstallerCore with `package_root: ~/.plastic` crashed with
|
|
84
|
+
# a raw Errno::ENOENT before this fallback existed. Raises a named error,
|
|
85
|
+
# not a bare ENOENT, when neither file exists, so the caller is not
|
|
86
|
+
# misdirected toward the wrong missing file.
|
|
79
87
|
def read_package_version(root)
|
|
80
|
-
File.
|
|
88
|
+
package_json = File.join(root, "package.json")
|
|
89
|
+
version_file = File.join(root, "VERSION")
|
|
90
|
+
|
|
91
|
+
if File.exist?(package_json)
|
|
92
|
+
JSON.parse(File.read(package_json))["version"]
|
|
93
|
+
elsif File.exist?(version_file)
|
|
94
|
+
File.read(version_file).strip
|
|
95
|
+
else
|
|
96
|
+
raise "cannot determine the package version: neither #{package_json} nor #{version_file} exists"
|
|
97
|
+
end
|
|
81
98
|
end
|
|
82
99
|
|
|
83
100
|
# --- Channel derivation (the channel is encoded in the version string) ---
|
|
@@ -314,12 +331,26 @@ class InstallerCore
|
|
|
314
331
|
end
|
|
315
332
|
end
|
|
316
333
|
|
|
334
|
+
# Intent 331a (D6/R7): a screen kind file (scripts/lib/screens/<kind>.rb)
|
|
335
|
+
# must reach an installed ~/.plastic the same way a new template or hook
|
|
336
|
+
# does - glob-derived, so "add a file, not a diff" is actually true for an
|
|
337
|
+
# installed Plastic, not just an in-repo one. This repo ships none yet;
|
|
338
|
+
# the glob answers {} until one exists.
|
|
339
|
+
def screen_files
|
|
340
|
+
Dir.glob(File.join(package_root, "scripts", "lib", "screens", "*.rb")).each_with_object({}) do |path, acc|
|
|
341
|
+
next unless File.file?(path)
|
|
342
|
+
|
|
343
|
+
rel = File.join("scripts", "lib", "screens", File.basename(path))
|
|
344
|
+
acc[rel] = rel
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
317
348
|
# Files copied into ~/.plastic on install/update. Every verb script + the shared lib
|
|
318
349
|
# must be here so the installed ~/.plastic/scripts copy is self-complete (sync-guarded
|
|
319
|
-
# by install_sync_test). The templates
|
|
320
|
-
# rest stays a hand-written literal.
|
|
350
|
+
# by install_sync_test). The templates and screen-kind halves are glob-derived
|
|
351
|
+
# (template_files, screen_files above); the rest stays a hand-written literal.
|
|
321
352
|
def core_files
|
|
322
|
-
hand_registered_files.merge(template_files).merge(hook_files)
|
|
353
|
+
hand_registered_files.merge(template_files).merge(hook_files).merge(screen_files)
|
|
323
354
|
end
|
|
324
355
|
|
|
325
356
|
def hand_registered_files
|
|
@@ -342,6 +373,9 @@ class InstallerCore
|
|
|
342
373
|
"scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
|
|
343
374
|
"scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
|
|
344
375
|
"scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
|
|
376
|
+
"scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
|
|
377
|
+
"scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
|
|
378
|
+
"scripts/node-transition" => "scripts/node-transition",
|
|
345
379
|
"scripts/lib/arm.rb" => "scripts/lib/arm.rb",
|
|
346
380
|
"scripts/lib/lock.rb" => "scripts/lib/lock.rb",
|
|
347
381
|
"scripts/plastic-lock" => "scripts/plastic-lock",
|
|
@@ -383,6 +417,9 @@ class InstallerCore
|
|
|
383
417
|
"scripts/lib/harness_text.rb" => "scripts/lib/harness_text.rb",
|
|
384
418
|
"scripts/codex-hook" => "scripts/codex-hook",
|
|
385
419
|
"scripts/spawn-preamble" => "scripts/spawn-preamble",
|
|
420
|
+
"scripts/lib/report_screen.rb" => "scripts/lib/report_screen.rb",
|
|
421
|
+
"scripts/report-screen" => "scripts/report-screen",
|
|
422
|
+
"scripts/savepoint-note" => "scripts/savepoint-note",
|
|
386
423
|
"scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
|
|
387
424
|
"scripts/provision-project-store" => "scripts/provision-project-store",
|
|
388
425
|
"scripts/lib/project_validator.rb" => "scripts/lib/project_validator.rb",
|
|
@@ -401,8 +438,11 @@ class InstallerCore
|
|
|
401
438
|
"scripts/verify-intent" => "scripts/verify-intent",
|
|
402
439
|
"scripts/lib/exec_worktree.rb" => "scripts/lib/exec_worktree.rb",
|
|
403
440
|
"scripts/exec-worktree" => "scripts/exec-worktree",
|
|
441
|
+
"scripts/lib/session_usage.rb" => "scripts/lib/session_usage.rb",
|
|
442
|
+
"scripts/session-usage" => "scripts/session-usage",
|
|
404
443
|
"scripts/doctor.rb" => "scripts/doctor.rb",
|
|
405
444
|
"scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
|
|
445
|
+
"scripts/lib/hook_replay.rb" => "scripts/lib/hook_replay.rb",
|
|
406
446
|
"scripts/lib/rule_catalog.rb" => "scripts/lib/rule_catalog.rb",
|
|
407
447
|
"scripts/lib/doctor_exclusions.rb" => "scripts/lib/doctor_exclusions.rb",
|
|
408
448
|
"scripts/lib/doctor_session_ledger.rb" => "scripts/lib/doctor_session_ledger.rb",
|
|
@@ -423,7 +463,102 @@ class InstallerCore
|
|
|
423
463
|
"scripts/lib/day_summary.rb" => "scripts/lib/day_summary.rb",
|
|
424
464
|
"scripts/write-handoff" => "scripts/write-handoff",
|
|
425
465
|
"scripts/day-summary" => "scripts/day-summary",
|
|
466
|
+
"scripts/lib/intent_screen.rb" => "scripts/lib/intent_screen.rb",
|
|
467
|
+
"scripts/intent-screen" => "scripts/intent-screen",
|
|
426
468
|
"scripts/hook-savepoint" => "scripts/hook-savepoint",
|
|
469
|
+
# Intent 316a: hooks/* only glob-copies scripts/*, never scripts/lib/*
|
|
470
|
+
# (see hook_files above), so the two lib files a require_relative
|
|
471
|
+
# between themselves are unguarded there — these three literal
|
|
472
|
+
# entries are their only protection (test/install_sync_test.rb:23-29
|
|
473
|
+
# greps installer_core.rb's own source text for "scripts/<name>").
|
|
474
|
+
"scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
|
|
475
|
+
"scripts/lib/screen_paint.rb" => "scripts/lib/screen_paint.rb",
|
|
476
|
+
"scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
|
|
477
|
+
# Intent 331d (A1): scripts/dashboard.rb require_relatives this lib
|
|
478
|
+
# directly; templates/dashboard-screen.md and scripts/lib/screens/
|
|
479
|
+
# dashboard.rb are glob-derived (template_files, screen_files above)
|
|
480
|
+
# and need no entry here.
|
|
481
|
+
"scripts/lib/dashboard_screen.rb" => "scripts/lib/dashboard_screen.rb",
|
|
482
|
+
"scripts/hook-message-display" => "scripts/hook-message-display",
|
|
483
|
+
# Intent 334 (G1): the node file and graph.md library, plus its
|
|
484
|
+
# validator and CLI (327 D40/D41).
|
|
485
|
+
"scripts/lib/graph_edges.rb" => "scripts/lib/graph_edges.rb",
|
|
486
|
+
"scripts/lib/node_file.rb" => "scripts/lib/node_file.rb",
|
|
487
|
+
"scripts/lib/atomic_write.rb" => "scripts/lib/atomic_write.rb",
|
|
488
|
+
"scripts/lib/graph_file.rb" => "scripts/lib/graph_file.rb",
|
|
489
|
+
"scripts/lib/work_graph_validator.rb" => "scripts/lib/work_graph_validator.rb",
|
|
490
|
+
"scripts/validate-work-graph" => "scripts/validate-work-graph",
|
|
491
|
+
# Intent 335a: every id an intent has ever seen, so a deleted node's id
|
|
492
|
+
# is never reissued to a new node with the dead one's ledger history.
|
|
493
|
+
"scripts/lib/node_ids.rb" => "scripts/lib/node_ids.rb",
|
|
494
|
+
# Intent 339 (G6): the generated outcome.md library and its CLI.
|
|
495
|
+
"scripts/lib/outcome_report.rb" => "scripts/lib/outcome_report.rb",
|
|
496
|
+
"scripts/outcome-report" => "scripts/outcome-report",
|
|
497
|
+
# Intent 336 (G3): ReadySet, the one function that says what may run
|
|
498
|
+
# next, and its CLI. node-transition requires the lib as of n5;
|
|
499
|
+
# registered here (rather than waiting for n8's own CLI/doc unit) so
|
|
500
|
+
# test/install_sync_test.rb stays green across every intermediate unit.
|
|
501
|
+
"scripts/lib/ready_set.rb" => "scripts/lib/ready_set.rb",
|
|
502
|
+
"scripts/ready-set" => "scripts/ready-set",
|
|
503
|
+
# Intent 337 (G4): the roadmap graph model and its tree renderer,
|
|
504
|
+
# registered as they land (test/installer_core_test.rb requires every
|
|
505
|
+
# scripts/lib file to be manifest-covered as soon as it exists, not
|
|
506
|
+
# only once the CLI that ships it arrives at n4).
|
|
507
|
+
"scripts/lib/roadmap_graph.rb" => "scripts/lib/roadmap_graph.rb",
|
|
508
|
+
"scripts/lib/graph_tree.rb" => "scripts/lib/graph_tree.rb",
|
|
509
|
+
"scripts/lib/index_projection.rb" => "scripts/lib/index_projection.rb",
|
|
510
|
+
"scripts/lib/roadmap_render.rb" => "scripts/lib/roadmap_render.rb",
|
|
511
|
+
"scripts/lib/roadmap_migration.rb" => "scripts/lib/roadmap_migration.rb",
|
|
512
|
+
"scripts/roadmap-graph" => "scripts/roadmap-graph",
|
|
513
|
+
"scripts/index-projection" => "scripts/index-projection",
|
|
514
|
+
# Intent 338 (G5): the node packet command - the trust-boundary wrapper,
|
|
515
|
+
# the five-block gatherer/assembler, and the CLI 340's runner calls.
|
|
516
|
+
"scripts/lib/packet_wrapper.rb" => "scripts/lib/packet_wrapper.rb",
|
|
517
|
+
"scripts/lib/node_packet.rb" => "scripts/lib/node_packet.rb",
|
|
518
|
+
"scripts/node-packet" => "scripts/node-packet",
|
|
519
|
+
# Intent 342 (G9): the backward shim that presents actions/*.md as a
|
|
520
|
+
# node graph for any legacy intent, so WorkGraphValidator can require
|
|
521
|
+
# it without going red on contact with install_sync_test.
|
|
522
|
+
"scripts/lib/action_graph_shim.rb" => "scripts/lib/action_graph_shim.rb",
|
|
523
|
+
# Intent 340 (G7, n1): the runner command - the subcommand table over
|
|
524
|
+
# the declared node graph, its shared context (RunnerCore), and the
|
|
525
|
+
# installed-core integrity check (CoreIntegrity) the runner's trust
|
|
526
|
+
# boundary and doctor will both call.
|
|
527
|
+
"scripts/runner" => "scripts/runner",
|
|
528
|
+
"scripts/lib/runner_core.rb" => "scripts/lib/runner_core.rb",
|
|
529
|
+
"scripts/lib/core_integrity.rb" => "scripts/lib/core_integrity.rb",
|
|
530
|
+
# Intent 340 (G7, n2): the merge abort, the reclaim, and the extension -
|
|
531
|
+
# the first thing every `step` does, routed from scripts/runner's `sweep`
|
|
532
|
+
# verb.
|
|
533
|
+
"scripts/lib/runner_sweep.rb" => "scripts/lib/runner_sweep.rb",
|
|
534
|
+
# Intent 340 (G7, n3): a work node's own git worktree, cut from the
|
|
535
|
+
# intent branch tip, merged back into the intent branch, and swept once
|
|
536
|
+
# its node is terminal.
|
|
537
|
+
"scripts/lib/node_worktree.rb" => "scripts/lib/node_worktree.rb",
|
|
538
|
+
# Intent 340 (G7, n4): the return schema (NodeReturn) and the six-check
|
|
539
|
+
# gate (RunnerAbsorb) that turns one executor return into exactly one
|
|
540
|
+
# node ledger transition, required lazily by scripts/runner's `step`.
|
|
541
|
+
"scripts/lib/node_return.rb" => "scripts/lib/node_return.rb",
|
|
542
|
+
"scripts/lib/runner_absorb.rb" => "scripts/lib/runner_absorb.rb",
|
|
543
|
+
# Intent 340 (G7, n5): validation, policy, leases and the dispatch plan
|
|
544
|
+
# - RunnerPolicy (the kind table) and RunnerDispatch, required lazily
|
|
545
|
+
# by scripts/runner's `step`.
|
|
546
|
+
"scripts/lib/runner_policy.rb" => "scripts/lib/runner_policy.rb",
|
|
547
|
+
"scripts/lib/runner_dispatch.rb" => "scripts/lib/runner_dispatch.rb",
|
|
548
|
+
# Intent 355 (n2): the call budget PreToolUse hook (RunnerPolicy.call_cap
|
|
549
|
+
# is its cap table, above); its launcher (hooks/call-budget) ships via
|
|
550
|
+
# hook_files' own glob, so only the hook script itself needs an entry.
|
|
551
|
+
"scripts/hook-call-budget" => "scripts/hook-call-budget",
|
|
552
|
+
"scripts/meter-watch" => "scripts/meter-watch",
|
|
553
|
+
"scripts/lib/meter_watch.rb" => "scripts/lib/meter_watch.rb",
|
|
554
|
+
# Intent 340 (G7, n6): answer (closes a decision node or unparks a
|
|
555
|
+
# work node parked at needs_decision), proposals (mints ids for what
|
|
556
|
+
# an executor proposed), and rewind (resets the intent branch to a
|
|
557
|
+
# node's own commit and respins it) - routed from scripts/runner's
|
|
558
|
+
# `answer` and `rewind` verbs.
|
|
559
|
+
"scripts/lib/runner_answer.rb" => "scripts/lib/runner_answer.rb",
|
|
560
|
+
"scripts/lib/runner_proposals.rb" => "scripts/lib/runner_proposals.rb",
|
|
561
|
+
"scripts/lib/runner_rewind.rb" => "scripts/lib/runner_rewind.rb",
|
|
427
562
|
}
|
|
428
563
|
end
|
|
429
564
|
|
|
@@ -437,8 +572,8 @@ class InstallerCore
|
|
|
437
572
|
version: 3
|
|
438
573
|
execution_mode: subagent-driven
|
|
439
574
|
stale_threshold_days: 3
|
|
440
|
-
context_offer_tokens:
|
|
441
|
-
context_insist_tokens:
|
|
575
|
+
context_offer_tokens: 150000
|
|
576
|
+
context_insist_tokens: 250000
|
|
442
577
|
hash_length: 6
|
|
443
578
|
hash_algorithm: sha256-base36
|
|
444
579
|
max_slug_words: 5
|