jekyll-theme-zer0 1.20.2 → 1.22.0

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.
@@ -89,6 +89,7 @@ def validate(data)
89
89
  return ['Missing or empty `tasks:` list.'] unless tasks.is_a?(Array) && !tasks.empty?
90
90
 
91
91
  seen_ids = {}
92
+ seen_links = {}
92
93
  tasks.each_with_index do |task, i|
93
94
  where = "tasks[#{i}]"
94
95
  unless task.is_a?(Hash)
@@ -114,6 +115,30 @@ def validate(data)
114
115
  unless task['acceptance'].is_a?(Array) && !task['acceptance'].empty?
115
116
  errors << "#{where}: `acceptance` must be a non-empty list."
116
117
  end
118
+
119
+ # Adoption guard: an existing GitHub issue may be claimed by at most one task,
120
+ # so two tasks can never fight over (or duplicate) the same issue.
121
+ lnk = task.dig('links', 'issue')
122
+ if lnk
123
+ unless lnk.is_a?(Integer) || lnk.to_s =~ /\A\d+\z/
124
+ errors << "#{where}: `links.issue` must be an issue number (got #{lnk.inspect})."
125
+ end
126
+ key = lnk.to_s
127
+ if seen_links[key]
128
+ errors << "#{where}: `links.issue` ##{key} already claimed by #{seen_links[key]}."
129
+ else
130
+ seen_links[key] = (id || where)
131
+ end
132
+ end
133
+ # Optional routing/dependency metadata (consumed by /issue-implement + /issue-plan).
134
+ if task.key?('route') && !task['route'].is_a?(String)
135
+ errors << "#{where}: `route` must be a string."
136
+ end
137
+ if task.key?('depends_on') &&
138
+ !(task['depends_on'].is_a?(Array) &&
139
+ task['depends_on'].all? { |d| d.is_a?(String) && d =~ /\AT-\d{3,}\z/ })
140
+ errors << "#{where}: `depends_on` must be a list of T-NNN ids."
141
+ end
117
142
  end
118
143
  errors
119
144
  end
@@ -133,6 +158,52 @@ def marker(id)
133
158
  "<!-- backlog-id: #{id} -->"
134
159
  end
135
160
 
161
+ # Delimiters around the sync-owned section of an ADOPTED human issue. Text ABOVE
162
+ # the start delimiter (the author's original report) is never touched; only this
163
+ # block is upserted on each sync, so adoption is non-destructive.
164
+ MANAGED_START = '<!-- backlog-managed:start -->'
165
+ MANAGED_END = '<!-- backlog-managed:end -->'
166
+
167
+ def render_managed_block(task)
168
+ accept = (task['acceptance'] || []).map { |a| "- [ ] #{a}" }.join("\n")
169
+ roadmap = task.dig('links', 'roadmap')
170
+ meta_row = [
171
+ "**Priority:** #{task['priority']}",
172
+ "**Area:** #{task['area']}",
173
+ "**Risk:** #{task['risk']}",
174
+ "**Effort:** #{task['effort']}",
175
+ "**Source:** #{task['source']}"
176
+ ].join(' · ')
177
+
178
+ <<~BLOCK.strip
179
+ #{MANAGED_START}
180
+ #{marker(task['id'])}
181
+ > Tracked from [`_data/backlog.yml`](../blob/main/_data/backlog.yml) by `scripts/sync-backlog.rb`.
182
+ > The report above is the author's; this block is auto-managed — edit the backlog, not here.
183
+
184
+ #{meta_row}#{roadmap ? " · **Roadmap:** v#{roadmap}" : ''}
185
+
186
+ ## Acceptance criteria
187
+
188
+ #{accept}
189
+
190
+ Picked up by the IMPLEMENT routine; see [`docs/systems/continuous-evolution.md`](../blob/main/docs/systems/continuous-evolution.md).
191
+ #{MANAGED_END}
192
+ BLOCK
193
+ end
194
+
195
+ # Upsert the managed block into a (human-authored) issue body, preserving the
196
+ # author's text. Idempotent: replaces an existing block, else appends one.
197
+ def upsert_managed_block(body, task)
198
+ body = body.to_s
199
+ block = render_managed_block(task)
200
+ if body.include?(MANAGED_START) && body.include?(MANAGED_END)
201
+ body.sub(/#{Regexp.escape(MANAGED_START)}.*#{Regexp.escape(MANAGED_END)}/m, block)
202
+ else
203
+ "#{body.rstrip}\n\n#{block}\n"
204
+ end
205
+ end
206
+
136
207
  def render_body(task)
137
208
  accept = (task['acceptance'] || []).map { |a| "- [ ] #{a}" }.join("\n")
138
209
  roadmap = task.dig('links', 'roadmap')
@@ -202,26 +273,52 @@ def ensure_labels(gh)
202
273
  VALID_RISK.each { |r| gh.write(['label', 'create', "risk:#{r}", '--color', RISK_COLOR, '--force']) }
203
274
  end
204
275
 
205
- # Map of backlog id -> existing issue {number, state, labels} via the body marker.
206
- def existing_issues(gh)
276
+ # Returns [index, link_state]:
277
+ # index — backlog id -> {number,state,labels,body,marker_id} for every
278
+ # marker-bearing agent-ready issue (the already-managed ones).
279
+ # link_state — issue number -> same record, for each issue referenced by a task
280
+ # `links.issue`. A human issue may carry neither the agent-ready
281
+ # label nor a marker yet, so it is fetched by number for adoption +
282
+ # provenance checks (is it already claimed by a foreign marker?).
283
+ def existing_issues(gh, link_numbers = [])
284
+ index = {}
285
+ link_state = {}
286
+ to_rec = lambda do |issue|
287
+ body = issue['body'].to_s
288
+ rec = {
289
+ 'number' => issue['number'],
290
+ 'state' => issue['state'].to_s.downcase,
291
+ 'labels' => (issue['labels'] || []).map { |l| l['name'] },
292
+ 'body' => body,
293
+ 'marker_id' => body[/<!-- backlog-id: (T-\d+) -->/, 1]
294
+ }
295
+ index[rec['marker_id']] = rec if rec['marker_id']
296
+ rec
297
+ end
298
+
207
299
  raw = gh.read(
208
300
  ['issue', 'list', '--label', 'agent-ready', '--state', 'all', '--limit', '500',
209
301
  '--json', 'number,body,state,labels'],
210
302
  default: '[]'
211
303
  )
212
- index = {}
213
- JSON.parse(raw).each do |issue|
214
- next unless issue['body'] =~ /<!-- backlog-id: (T-\d+) -->/
304
+ begin
305
+ JSON.parse(raw).each { |issue| to_rec.call(issue) }
306
+ rescue JSON::ParserError
307
+ # leave index empty; adoption/create still work off link_state
308
+ end
215
309
 
216
- index[Regexp.last_match(1)] = {
217
- 'number' => issue['number'],
218
- 'state' => issue['state'].to_s.downcase,
219
- 'labels' => (issue['labels'] || []).map { |l| l['name'] }
220
- }
310
+ link_numbers.compact.map(&:to_i).uniq.each do |n|
311
+ raw1 = gh.read(['issue', 'view', n.to_s, '--json', 'number,body,state,labels'], default: '')
312
+ next if raw1.strip.empty?
313
+
314
+ begin
315
+ link_state[n] = to_rec.call(JSON.parse(raw1))
316
+ rescue JSON::ParserError
317
+ next
318
+ end
221
319
  end
222
- index
223
- rescue JSON::ParserError
224
- {}
320
+
321
+ [index, link_state]
225
322
  end
226
323
 
227
324
  # ---------------------------------------------------------------------------
@@ -241,29 +338,55 @@ end
241
338
 
242
339
  def sync(data, gh)
243
340
  ensure_labels(gh)
244
- index = existing_issues(gh)
245
- created = updated = closed = 0
246
-
247
- (data['tasks'] || []).each do |task|
248
- id = task['id']
249
- title = task['title']
250
- body = render_body(task)
341
+ tasks = data['tasks'] || []
342
+ link_numbers = tasks.map { |t| t.dig('links', 'issue') }.compact
343
+ index, link_state = existing_issues(gh, link_numbers)
344
+ created = adopted = updated = closed = 0
345
+
346
+ tasks.each do |task|
347
+ id = task['id']
348
+ title = task['title']
251
349
  want_open = OPEN_STATUSES.include?(task['status'])
252
- issue = index[id]
350
+ lnk = task.dig('links', 'issue')
351
+ issue = index[id] # already marker-tracked?
352
+
353
+ # Adoption: task points at an existing issue not yet marker-tracked. Adopt it
354
+ # (inject the managed block) instead of creating a duplicate — unless the
355
+ # issue already carries a DIFFERENT marker (foreign provenance → refuse).
356
+ if issue.nil? && lnk
357
+ ls = link_state[lnk.to_i]
358
+ if ls.nil?
359
+ warn "task #{id}: links.issue ##{lnk} not found — creating a new issue instead."
360
+ elsif ls['marker_id'] && ls['marker_id'] != id
361
+ warn "task #{id}: refusing to adopt ##{lnk} — already marked #{ls['marker_id']} (foreign). Skipping."
362
+ next
363
+ else
364
+ issue = ls.merge('adopt' => true)
365
+ end
366
+ end
253
367
 
254
368
  if issue.nil?
255
369
  next unless want_open # never create an issue for an already-done task
256
370
 
257
- args = ['issue', 'create', '--title', title, '--body', body]
371
+ args = ['issue', 'create', '--title', title, '--body', render_body(task)]
258
372
  managed_labels(task).each { |l| args.push('--label', l) }
259
373
  created += 1 if gh.write(args)
260
374
  next
261
375
  end
262
376
 
263
377
  number = issue['number'].to_s
264
- gh.write(['issue', 'edit', number, '--title', title, '--body', body] +
265
- label_args(managed_labels(task), issue['labels']))
266
- updated += 1
378
+ if lnk
379
+ # Human-authored (adopted) issue: NEVER overwrite its title/body — only
380
+ # upsert the managed block (preserving the author's report) + labels.
381
+ gh.write(['issue', 'edit', number, '--body', upsert_managed_block(issue['body'], task)] +
382
+ label_args(managed_labels(task), issue['labels']))
383
+ issue['adopt'] ? (adopted += 1) : (updated += 1)
384
+ else
385
+ # Bot-created issue: fully sync-owned title + body.
386
+ gh.write(['issue', 'edit', number, '--title', title, '--body', render_body(task)] +
387
+ label_args(managed_labels(task), issue['labels']))
388
+ updated += 1
389
+ end
267
390
 
268
391
  if want_open && issue['state'] != 'open'
269
392
  gh.write(['issue', 'reopen', number])
@@ -273,7 +396,7 @@ def sync(data, gh)
273
396
  end
274
397
  end
275
398
 
276
- puts "Backlog sync complete: #{created} created, #{updated} updated, #{closed} closed."
399
+ puts "Backlog sync complete: #{created} created, #{adopted} adopted, #{updated} updated, #{closed} closed."
277
400
  0
278
401
  end
279
402
 
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # =============================================================================
5
+ # sync-plan.rb
6
+ # =============================================================================
7
+ #
8
+ # Validates (and optionally mirrors) `_data/roadmap_plan.yml` — the ORDER-ONLY
9
+ # plan artifact produced by the /issue-plan committee. The plan sequences open
10
+ # backlog tasks into batches; it must NEVER re-encode anything the backlog owns
11
+ # (risk / priority / area / status / effort), which would create a second source
12
+ # of truth that drifts.
13
+ #
14
+ # ruby scripts/sync-plan.rb --check # validate the plan vs the backlog (CI/PR gate; stdlib only)
15
+ # ruby scripts/sync-plan.rb --dry-run # print the intended pinned-issue upsert
16
+ # ruby scripts/sync-plan.rb # upsert one pinned tracking issue via `gh`
17
+ #
18
+ # --check asserts:
19
+ # * every batch task id exists in _data/backlog.yml and is OPEN (not done),
20
+ # * batch `depends_on` references existing batch ids and the batch DAG is acyclic,
21
+ # * no plan entry carries a backlog-owned field (order-only invariant).
22
+ # =============================================================================
23
+
24
+ require 'yaml'
25
+ require 'date'
26
+ require 'json'
27
+ require 'optparse'
28
+ require 'open3'
29
+
30
+ ROOT = File.expand_path('..', __dir__)
31
+ PLAN_FILE = File.join(ROOT, '_data', 'roadmap_plan.yml')
32
+ BACKLOG = File.join(ROOT, '_data', 'backlog.yml')
33
+
34
+ PIN_MARKER = '<!-- roadmap-plan:pinned -->'
35
+ BACKLOG_OWNED = %w[risk priority area status effort source acceptance title].freeze
36
+ OPEN_TASK_STATUS = %w[open in-progress blocked].freeze
37
+
38
+ def load_yaml(path)
39
+ YAML.load_file(path, permitted_classes: [Date, Time])
40
+ rescue ArgumentError
41
+ YAML.safe_load(File.read(path, encoding: 'UTF-8'), permitted_classes: [Date, Time], aliases: false)
42
+ end
43
+
44
+ def backlog_status
45
+ tasks = (load_yaml(BACKLOG)['tasks'] || [])
46
+ tasks.each_with_object({}) { |t, h| h[t['id']] = t['status'] }
47
+ end
48
+
49
+ def validate(plan, statuses)
50
+ errors = []
51
+ unless plan.is_a?(Hash) && plan['batches'].is_a?(Array)
52
+ return ['plan must have a `batches:` list.']
53
+ end
54
+
55
+ batch_ids = plan['batches'].map { |b| b['id'] }.compact
56
+ seen = {}
57
+ batch_ids.each { |bid| seen[bid] ? (errors << "duplicate batch id #{bid}.") : (seen[bid] = true) }
58
+
59
+ plan['batches'].each_with_index do |batch, i|
60
+ where = batch['id'] ? "batch #{batch['id']}" : "batches[#{i}]"
61
+ errors << "#{where}: missing `id`." if batch['id'].to_s.empty?
62
+
63
+ # Order-only invariant: a batch (or its task entries) may not carry a field
64
+ # the backlog owns. Tasks must be bare id strings.
65
+ (BACKLOG_OWNED & batch.keys).each { |k| errors << "#{where}: must not carry backlog-owned field `#{k}`." }
66
+ Array(batch['tasks']).each do |t|
67
+ unless t.is_a?(String) && t =~ /\AT-\d{3,}\z/
68
+ errors << "#{where}: task entries must be bare T-NNN ids (got #{t.inspect})."
69
+ next
70
+ end
71
+ if !statuses.key?(t)
72
+ errors << "#{where}: task #{t} is not in the backlog."
73
+ elsif !OPEN_TASK_STATUS.include?(statuses[t])
74
+ errors << "#{where}: task #{t} is #{statuses[t]} (only open tasks may be planned)."
75
+ end
76
+ end
77
+
78
+ Array(batch['depends_on']).each do |dep|
79
+ errors << "#{where}: depends_on references unknown batch #{dep}." unless seen[dep]
80
+ end
81
+ end
82
+
83
+ errors.concat(cycle_errors(plan['batches']))
84
+ errors
85
+ end
86
+
87
+ # Kahn's algorithm over the batch DAG; any leftover nodes => a cycle.
88
+ def cycle_errors(batches)
89
+ deps = {}
90
+ batches.each { |b| deps[b['id']] = Array(b['depends_on']).dup }
91
+ indeg = Hash.new(0)
92
+ deps.each { |_n, ds| ds.each { |d| indeg[d] += 1 if deps.key?(d) } }
93
+ # Edge dep -> node (dep must precede node); compute order by repeatedly removing
94
+ # nodes whose deps are all satisfied.
95
+ remaining = deps.keys
96
+ progress = true
97
+ while progress
98
+ progress = false
99
+ ready = remaining.select { |n| deps[n].all? { |d| !remaining.include?(d) } }
100
+ unless ready.empty?
101
+ remaining -= ready
102
+ progress = true
103
+ end
104
+ end
105
+ remaining.empty? ? [] : ["batch dependency cycle among: #{remaining.sort.join(', ')}."]
106
+ end
107
+
108
+ def pinned_body(plan)
109
+ lines = ["#{PIN_MARKER}", '> Auto-managed by `scripts/sync-plan.rb` from `_data/roadmap_plan.yml`.',
110
+ '> Order only — risk/priority/area come from `_data/backlog.yml`.', '']
111
+ plan['batches'].each do |b|
112
+ lines << "### #{b['id']} — #{b['goal']}"
113
+ lines << "Tasks: #{Array(b['tasks']).join(', ')}"
114
+ lines << "Depends on: #{Array(b['depends_on']).join(', ')}" unless Array(b['depends_on']).empty?
115
+ lines << "Test framework: #{b['test_framework']}" if b['test_framework']
116
+ lines << ''
117
+ end
118
+ lines.join("\n").strip
119
+ end
120
+
121
+ def upsert_pinned(plan, dry_run:)
122
+ title = '📋 Roadmap plan (auto-managed)'
123
+ body = pinned_body(plan)
124
+ found = `gh issue list --search #{PIN_MARKER.inspect}\\ in:body --state open --json number --jq '.[0].number' 2>/dev/null`.strip
125
+ args =
126
+ if found.empty?
127
+ ['issue', 'create', '--title', title, '--body', body, '--label', 'agent-hold']
128
+ else
129
+ ['issue', 'edit', found, '--title', title, '--body', body]
130
+ end
131
+ if dry_run
132
+ puts "DRY-RUN gh #{args.join(' ')}"
133
+ return
134
+ end
135
+ _o, err, st = Open3.capture3('gh', *args)
136
+ warn "gh failed: #{err}" unless st.success?
137
+ end
138
+
139
+ def main
140
+ mode = :sync
141
+ OptionParser.new do |o|
142
+ o.on('--check') { mode = :check }
143
+ o.on('--dry-run') { mode = :dry_run }
144
+ end.parse!
145
+
146
+ return 0 unless File.exist?(PLAN_FILE) # no plan yet => nothing to validate
147
+
148
+ plan = load_yaml(PLAN_FILE)
149
+ errors = validate(plan, backlog_status)
150
+ unless errors.empty?
151
+ warn '✗ _data/roadmap_plan.yml failed validation:'
152
+ errors.each { |e| warn " - #{e}" }
153
+ return 1
154
+ end
155
+
156
+ if mode == :check
157
+ puts "✓ _data/roadmap_plan.yml is valid (#{plan['batches'].size} batches)."
158
+ return 0
159
+ end
160
+ upsert_pinned(plan, dry_run: mode == :dry_run)
161
+ 0
162
+ end
163
+
164
+ exit main if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # sync-plan.sh
4
+ # =============================================================================
5
+ #
6
+ # Thin wrapper around scripts/sync-plan.rb.
7
+ #
8
+ # Validates (and optionally mirrors) `_data/roadmap_plan.yml` — the order-only
9
+ # plan artifact produced by the /issue-plan committee — against the backlog.
10
+ #
11
+ # Usage:
12
+ # ./scripts/sync-plan.sh # upsert the pinned tracking issue via gh
13
+ # ./scripts/sync-plan.sh --check # validate plan vs backlog (CI/PR gate)
14
+ # ./scripts/sync-plan.sh --dry-run # print the intended gh call only
15
+ #
16
+ # =============================================================================
17
+
18
+ set -euo pipefail
19
+
20
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ exec ruby "${SCRIPT_DIR}/sync-plan.rb" "$@"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-theme-zer0
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.20.2
4
+ version: 1.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amr Abdel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-25 00:00:00.000000000 Z
11
+ date: 2026-06-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jekyll
@@ -99,6 +99,8 @@ files:
99
99
  - _data/prerequisites.yml
100
100
  - _data/prompts.yml
101
101
  - _data/roadmap.yml
102
+ - _data/roadmap_plan.yml
103
+ - _data/routing.yml
102
104
  - _data/statistics_config.yml
103
105
  - _data/theme-manifest.yml
104
106
  - _data/theme_backgrounds.yml
@@ -374,6 +376,7 @@ files:
374
376
  - scripts/analyze-commits.sh
375
377
  - scripts/bin/audit-consumer
376
378
  - scripts/bin/build
379
+ - scripts/bin/giscus-discussions
377
380
  - scripts/bin/install
378
381
  - scripts/bin/manifest
379
382
  - scripts/bin/release
@@ -491,6 +494,8 @@ files:
491
494
  - scripts/setup.sh
492
495
  - scripts/sync-backlog.rb
493
496
  - scripts/sync-backlog.sh
497
+ - scripts/sync-plan.rb
498
+ - scripts/sync-plan.sh
494
499
  - scripts/test-auto-version.sh
495
500
  - scripts/test-mermaid.sh
496
501
  - scripts/test-notebook-conversion.sh