@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.20
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/hooks/hooks.json +12 -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-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- 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/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 +101 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +121 -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 +14 -0
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +131 -4
- 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/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 +886 -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 +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -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/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -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/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 +392 -0
- package/scripts/savepoint-note +69 -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 +24 -17
- 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 +17 -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/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
package/scripts/end-intent
CHANGED
|
@@ -66,6 +66,8 @@
|
|
|
66
66
|
# refused before removal; pass --discard-worktree-changes to override
|
|
67
67
|
# deliberately.
|
|
68
68
|
# 6 retired in 2.0 (intent 308): the structure check reports and proceeds.
|
|
69
|
+
# 7 hollow delivered report refused (317a D7) - intent 308's one deliberate
|
|
70
|
+
# exception to report-and-proceed; --allow-hollow-report overrides.
|
|
69
71
|
|
|
70
72
|
require "fileutils"
|
|
71
73
|
require "date"
|
|
@@ -77,7 +79,9 @@ require_relative "lib/savepoint"
|
|
|
77
79
|
require_relative "lib/lock"
|
|
78
80
|
require_relative "lib/intent_validator"
|
|
79
81
|
require_relative "lib/outcome_guard"
|
|
82
|
+
require_relative "lib/report_screen"
|
|
80
83
|
require_relative "lib/backfill_intent"
|
|
84
|
+
require_relative "lib/outcome_report"
|
|
81
85
|
|
|
82
86
|
DISPOSITIONS = %w[delivered abandoned].freeze
|
|
83
87
|
|
|
@@ -99,6 +103,7 @@ def parse_args(argv)
|
|
|
99
103
|
opts = {
|
|
100
104
|
store: nil, id: nil, disposition: nil, index: nil,
|
|
101
105
|
outcome_summary: nil, index_note: nil, no_commit: false, dry_run: false,
|
|
106
|
+
allow_hollow_report: false,
|
|
102
107
|
session: nil, discard_worktree_changes: false,
|
|
103
108
|
}
|
|
104
109
|
i = 0
|
|
@@ -115,6 +120,7 @@ def parse_args(argv)
|
|
|
115
120
|
when "--discard-worktree-changes" then opts[:discard_worktree_changes] = true
|
|
116
121
|
when "--no-commit" then opts[:no_commit] = true
|
|
117
122
|
when "--dry-run" then opts[:dry_run] = true
|
|
123
|
+
when "--allow-hollow-report" then opts[:allow_hollow_report] = true
|
|
118
124
|
else
|
|
119
125
|
usage_abort("unknown argument #{arg.inspect}")
|
|
120
126
|
end
|
|
@@ -171,6 +177,64 @@ def resolve_plastic_home_and_scope(store)
|
|
|
171
177
|
end
|
|
172
178
|
end
|
|
173
179
|
|
|
180
|
+
# --- outcome generation, before the backfill (intent 339, G6, spec D10) -----
|
|
181
|
+
#
|
|
182
|
+
# Generates outcome.md from graph.md, nodes/, and the ledger, BEFORE the
|
|
183
|
+
# backfill step runs, and only into a file that is missing or still the
|
|
184
|
+
# scaffold placeholder (`Savepoint.stage_file_present?` false) - a real,
|
|
185
|
+
# hand-written outcome.md is never touched (row 7.2). Only when a graph.md
|
|
186
|
+
# exists at all (row 7.3): every intent without one closes exactly as it did
|
|
187
|
+
# before this intent. The model is parsed AFTER the `stage_file_present?`
|
|
188
|
+
# guard, not before: a real hand-written outcome.md returns at that guard
|
|
189
|
+
# regardless of graph.md's own health, so a close that was never going to
|
|
190
|
+
# generate anything must never print a parse warning about it. Refuses a
|
|
191
|
+
# report model that reports itself broken (row v1f.2, B2): a malformed
|
|
192
|
+
# `## Graph` fails `model[:ok]` and the close falls straight through to the
|
|
193
|
+
# backfill, rather than adopting parse damage as delivery fact - agreeing
|
|
194
|
+
# with the shipped `outcome-report` CLI, which exits 3 on the same
|
|
195
|
+
# directory. The generated text is then checked against
|
|
196
|
+
# BOTH close gates - `OutcomeGuard` and the hollow-report gate below - before
|
|
197
|
+
# being adopted (row 7.4); when either would refuse it, the write is
|
|
198
|
+
# reverted and the close falls through to intent 308's unchanged backfill
|
|
199
|
+
# (row 7.5), so no close gains an exit 7 it did not have before this intent.
|
|
200
|
+
# Fails open on any exception (row 7.6), the same report-and-proceed shape
|
|
201
|
+
# every other close step in this file uses. `generator:` is the injectable
|
|
202
|
+
# seam (mirrors run_structure_check's `gate:`).
|
|
203
|
+
def run_generate_outcome(intent_dir, disposition:, generator: OutcomeReport.method(:write))
|
|
204
|
+
graph_path = File.join(intent_dir, "graph.md")
|
|
205
|
+
return unless File.exist?(graph_path)
|
|
206
|
+
|
|
207
|
+
outcome_path = File.join(intent_dir, "outcome.md")
|
|
208
|
+
return if Savepoint.stage_file_present?(outcome_path)
|
|
209
|
+
|
|
210
|
+
report_model = OutcomeReport.model(intent_dir)
|
|
211
|
+
unless report_model[:ok]
|
|
212
|
+
warn "end-intent: graph.md failed to parse (#{report_model[:errors].join('; ')}); falling back to the backfill"
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
original_exists = File.exist?(outcome_path)
|
|
217
|
+
original_content = original_exists ? File.read(outcome_path) : nil
|
|
218
|
+
|
|
219
|
+
generator.call(intent_dir, disposition: disposition)
|
|
220
|
+
|
|
221
|
+
guard_reason = OutcomeGuard.reason(intent_dir, disposition)
|
|
222
|
+
hollow = hollow_report_reason(intent_dir, disposition)
|
|
223
|
+
if guard_reason || hollow
|
|
224
|
+
if original_exists
|
|
225
|
+
File.write(outcome_path, original_content)
|
|
226
|
+
elsif File.exist?(outcome_path)
|
|
227
|
+
File.delete(outcome_path)
|
|
228
|
+
end
|
|
229
|
+
warn "end-intent: generated outcome.md would be refused (#{[guard_reason, hollow].compact.join('; ')}); " \
|
|
230
|
+
"falling back to the backfill"
|
|
231
|
+
else
|
|
232
|
+
warn "end-intent: outcome.md generated from graph.md and the ledger"
|
|
233
|
+
end
|
|
234
|
+
rescue StandardError => e
|
|
235
|
+
warn "end-intent: outcome generation crashed (#{e.message}); proceeding without it"
|
|
236
|
+
end
|
|
237
|
+
|
|
174
238
|
# Backfill (intent 308): write the judgment documents that are missing or still the
|
|
175
239
|
# placeholder from the record (BackfillIntent). Fail-open: a crash here warns and the
|
|
176
240
|
# close proceeds, the same contract as the structure check below.
|
|
@@ -215,7 +279,137 @@ end
|
|
|
215
279
|
# Replace the body of the intent file's `## Outcome` section with `summary`,
|
|
216
280
|
# leaving every other section and the frontmatter untouched. No-op (returns
|
|
217
281
|
# content unchanged) when summary is blank or the section is not found.
|
|
282
|
+
# 317a S9 (D7): the self-render gate. Renders the delivered screen's sources on
|
|
283
|
+
# the intent's own record and names what would come out hollow. This is intent
|
|
284
|
+
# 308's ONE deliberate exception to report-and-proceed: a hollow DELIVERED
|
|
285
|
+
# close is refused with exit 7 (--allow-hollow-report overrides), because 317
|
|
286
|
+
# closed exactly this way and the owner read a screen full of "not recorded".
|
|
287
|
+
# Abandoned closes and machine-backfilled outcomes (the backfill marker) are
|
|
288
|
+
# exempt: the reader cannot expect labels the writer never had.
|
|
289
|
+
#
|
|
290
|
+
# Intent 334 (G1, fold A3; post-execution review, blocking 2): the legacy
|
|
291
|
+
# "S3" label grammar applies only to actions/*.md headings, and the node id
|
|
292
|
+
# grammar (kind prefix n/v/d/r plus digits, D1r) applies only to nodes/*.md
|
|
293
|
+
# headings - never both over both directories. This repo's own house words
|
|
294
|
+
# for plan review and post-execution review are "v1" and "v2"; widening the
|
|
295
|
+
# node-id alternative onto actions/ headings turned that prose into a
|
|
296
|
+
# manufactured label and refused legitimate legacy closes at exit 7.
|
|
297
|
+
ACTION_ID_LABEL_RE = /\AS\d+\z/.freeze
|
|
298
|
+
NODE_ID_LABEL_RE = /\A[nvdr][1-9]\d*\z/.freeze
|
|
299
|
+
|
|
300
|
+
# The id-shaped tokens found in `paths`' headings, matched against
|
|
301
|
+
# `id_label_re` only - the caller picks the grammar for the directory.
|
|
302
|
+
def heading_id_labels(paths, id_label_re)
|
|
303
|
+
paths.flat_map do |path|
|
|
304
|
+
ReportScreen.split_by_headings(File.read(path)).flat_map do |heading, _body|
|
|
305
|
+
heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).select { |tok| tok.match?(id_label_re) }
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# 339 S9 (D18): a node id counts as a label in an actions/*.md heading too,
|
|
311
|
+
# but only when it stands alone as one of the heading's " - "-delimited
|
|
312
|
+
# segments - this repo's own convention, "### S1 - n1 - Title" (this action
|
|
313
|
+
# file's own headings, post-fold) or the bare "### n1 - Title". Never as a
|
|
314
|
+
# free word inside ordinary prose: the comment above ACTION_ID_LABEL_RE
|
|
315
|
+
# records exactly why intent 334 restricted the node-id grammar to
|
|
316
|
+
# nodes/*.md headings in the first place, and that reasoning does not change
|
|
317
|
+
# here - a word-scan widened onto actions/ headings would let this repo's own
|
|
318
|
+
# house words ("v1", "v2" for plan review and post-execution review)
|
|
319
|
+
# manufacture a label out of ordinary prose and refuse a legitimate legacy
|
|
320
|
+
# close. Segment position is what a word-scan cannot see: "v2" embedded
|
|
321
|
+
# mid-sentence is never its own " - "-delimited segment, but a heading built
|
|
322
|
+
# to this convention always puts the node id in one.
|
|
323
|
+
def heading_node_id_segments(paths, id_label_re)
|
|
324
|
+
paths.flat_map do |path|
|
|
325
|
+
ReportScreen.split_by_headings(File.read(path)).flat_map do |heading, _body|
|
|
326
|
+
heading.to_s.sub(/\A#+\s*/, "").split(/\s+-\s+/).map(&:strip).select { |seg| seg.match?(id_label_re) }
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def hollow_report_reason(intent_dir, disposition)
|
|
332
|
+
return nil unless disposition == "delivered"
|
|
333
|
+
|
|
334
|
+
outcome = File.join(intent_dir, "outcome.md")
|
|
335
|
+
return nil unless File.exist?(outcome)
|
|
336
|
+
text = File.read(outcome)
|
|
337
|
+
return nil if text.include?("<!-- backfilled from the record by end-intent on ")
|
|
338
|
+
|
|
339
|
+
# The gate's evidence bar (A7/B7): it judges only records that CLAIM a
|
|
340
|
+
# labeled convention - at least one actions/*.md heading carrying an
|
|
341
|
+
# S-label or a node-id segment, or at least one nodes/*.md heading carrying
|
|
342
|
+
# a node id ("n1", "v2", "d3", "r4"). A terse legacy close with no labeled
|
|
343
|
+
# matrix stays report-and-proceed (intent 334, G1, D10r).
|
|
344
|
+
#
|
|
345
|
+
# Every matching token per heading is kept, not just the first (fold A3):
|
|
346
|
+
# a legacy actions/ heading can carry BOTH an S-label and prose that reads
|
|
347
|
+
# like a node id ("### The v2 rewrite (S3)"). Restricting the word-scan
|
|
348
|
+
# node-id grammar to nodes/*.md headings (post-execution review, blocking
|
|
349
|
+
# 2) means "v2" in that heading is never even tested against it, so the
|
|
350
|
+
# heading's real "S3" label is never at risk.
|
|
351
|
+
#
|
|
352
|
+
# 339 S9 (D18): a graph-era action file headed by node id alone ("### n1 -
|
|
353
|
+
# Title", no S-label) used to yield an empty label set here and the gate
|
|
354
|
+
# returned before judging anything - every such record closed unjudged by
|
|
355
|
+
# silent exemption. `heading_node_id_segments` widens the actions/*.md side
|
|
356
|
+
# to see that shape without reopening the word-scan hole above: it matches
|
|
357
|
+
# a node id only when the heading's own " - "-delimited segments carry one,
|
|
358
|
+
# never a free word in prose. The node_paths call stays exactly as it was;
|
|
359
|
+
# nodes/*.md headings already resolve node ids correctly (test_node_id_
|
|
360
|
+
# labels_are_recognized) and their heading grammar is controlled, so a
|
|
361
|
+
# word-scan there carries none of the prose risk.
|
|
362
|
+
action_paths = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort
|
|
363
|
+
node_paths = Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
|
|
364
|
+
heading_labels = (heading_id_labels(action_paths, ACTION_ID_LABEL_RE) +
|
|
365
|
+
heading_node_id_segments(action_paths, NODE_ID_LABEL_RE) +
|
|
366
|
+
heading_id_labels(node_paths, NODE_ID_LABEL_RE)).uniq
|
|
367
|
+
return nil if heading_labels.empty?
|
|
368
|
+
|
|
369
|
+
rows = ReportScreen.delivered_rows(intent_dir)
|
|
370
|
+
return "outcome.md ## Delivered renders no rows although a labeled action matrix exists" if rows.empty?
|
|
371
|
+
|
|
372
|
+
needs = ReportScreen.section_of(text, "## Needs you").gsub(/<!--.*?-->/m, "").strip
|
|
373
|
+
if !needs.empty? && needs != "None" && ReportScreen.needs_you_rows(intent_dir).empty?
|
|
374
|
+
return "## Needs you has content that would render as None"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
labels = rows.map { |r| r[:label] }
|
|
378
|
+
if (labels & heading_labels).empty?
|
|
379
|
+
return "delivered row labels (#{labels.first(3).join(', ')}...) match no action-file heading (#{heading_labels.first(3).join(', ')}...)"
|
|
380
|
+
end
|
|
381
|
+
if rows.all? { |r| ReportScreen.proven_by(intent_dir, r[:label]) == ReportScreen::NOT_RECORDED }
|
|
382
|
+
return "every Proven-by cell renders not recorded although labeled action headings exist"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
nil
|
|
386
|
+
rescue StandardError => e
|
|
387
|
+
warn "end-intent: hollow-report gate crashed (#{e.message}); proceeding without it"
|
|
388
|
+
nil
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# 317a S7 (D5, A8): stamp the live lock's run_mode into outcome.md frontmatter
|
|
392
|
+
# INSIDE the close step - after the gate, before the INDEX move and the store
|
|
393
|
+
# commit - so the stamped file rides the close commit and mode survives disarm.
|
|
394
|
+
def stamp_outcome_mode(intent_dir)
|
|
395
|
+
data = Lock.read(intent_dir)
|
|
396
|
+
run_mode = data && data["run_mode"].to_s
|
|
397
|
+
return if run_mode.nil? || run_mode.empty?
|
|
398
|
+
|
|
399
|
+
path = File.join(intent_dir, "outcome.md")
|
|
400
|
+
return unless File.exist?(path)
|
|
401
|
+
text = File.read(path)
|
|
402
|
+
return unless text.start_with?("---")
|
|
403
|
+
head, body = text[3..].split("---", 2)
|
|
404
|
+
return if body.nil? || head.match?(/^mode:/)
|
|
405
|
+
|
|
406
|
+
File.write(path, "---#{head}mode: #{run_mode}\n---#{body}")
|
|
407
|
+
rescue StandardError => e
|
|
408
|
+
warn "end-intent: mode stamp failed (#{e.message}); proceeding without it"
|
|
409
|
+
end
|
|
410
|
+
|
|
218
411
|
def stamp_outcome_summary(content, summary)
|
|
412
|
+
|
|
219
413
|
return content if summary.nil? || summary.to_s.strip.empty?
|
|
220
414
|
|
|
221
415
|
lines = content.lines
|
|
@@ -645,6 +839,11 @@ def main(argv)
|
|
|
645
839
|
end
|
|
646
840
|
end
|
|
647
841
|
|
|
842
|
+
# 0a. Generate outcome.md from graph.md and the ledger, when there is one
|
|
843
|
+
# (intent 339, G6, spec D10) - before the backfill, so a real graph never
|
|
844
|
+
# falls through to the terse backfill it exists to replace.
|
|
845
|
+
run_generate_outcome(intent_dir, disposition: disposition)
|
|
846
|
+
|
|
648
847
|
# 1. Backfill from the record (intent 308), then the structure self-check and the
|
|
649
848
|
# outcome guard, both report-only since 308.
|
|
650
849
|
run_backfill(intent_dir, store: store, id: id, disposition: disposition,
|
|
@@ -654,6 +853,26 @@ def main(argv)
|
|
|
654
853
|
reason = OutcomeGuard.reason(intent_dir, disposition)
|
|
655
854
|
warn "end-intent: outcome.md: #{reason} (proceeding; doctor --intent #{id} reports it)" if reason
|
|
656
855
|
|
|
856
|
+
# 1a2. The self-render gate (317a S9, D7): refuse a hollow delivered close
|
|
857
|
+
# BEFORE any terminal mutation - INDEX, savepoint, and store stay untouched
|
|
858
|
+
# on exit 7 (A9).
|
|
859
|
+
hollow = hollow_report_reason(intent_dir, disposition)
|
|
860
|
+
if hollow
|
|
861
|
+
if opts[:allow_hollow_report]
|
|
862
|
+
warn "end-intent: hollow delivered report overridden (--allow-hollow-report): #{hollow}"
|
|
863
|
+
else
|
|
864
|
+
warn "end-intent: refusing a hollow delivered close: #{hollow}"
|
|
865
|
+
warn "end-intent: write outcome.md's ## Delivered as the labeled | Row | What | table " \
|
|
866
|
+
"matching the action-file headings (templates/outcome.md shows the shape), " \
|
|
867
|
+
"or pass --allow-hollow-report to close anyway"
|
|
868
|
+
exit 7
|
|
869
|
+
end
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
# 1a3. Mode stamp (317a S7, D5): run_mode into outcome frontmatter while the
|
|
873
|
+
# lock still exists, so the stamped file rides the close commit.
|
|
874
|
+
stamp_outcome_mode(intent_dir)
|
|
875
|
+
|
|
657
876
|
# 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
|
|
658
877
|
if opts[:outcome_summary]
|
|
659
878
|
intent_file = Savepoint.intent_file(intent_dir)
|
package/scripts/hook-capture
CHANGED
|
@@ -5,12 +5,11 @@
|
|
|
5
5
|
# Usage: hook-capture (reads the UserPromptSubmit stdin JSON payload)
|
|
6
6
|
# Intent 298. One UserPromptSubmit process replacing hook-continue,
|
|
7
7
|
# hook-future-intent-check, and hook-auto-arm: it appends a pending line to
|
|
8
|
-
# the session day ledger
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# the session day ledger and detects "continue" and "auto" prompts. Every job
|
|
9
|
+
# runs in its own rescue and the hook always exits 0 (spec D2). Intent 345
|
|
10
|
+
# (D7, 323) removed the per-prompt Future-intent hint step entirely.
|
|
11
11
|
|
|
12
12
|
require "json"
|
|
13
|
-
require "yaml"
|
|
14
13
|
require "open3"
|
|
15
14
|
require "fileutils"
|
|
16
15
|
require_relative "lib/session_ledger"
|
|
@@ -31,7 +30,8 @@ end
|
|
|
31
30
|
exit 0 unless payload.is_a?(Hash)
|
|
32
31
|
|
|
33
32
|
session_id = payload["session_id"].to_s
|
|
34
|
-
prompt = payload["
|
|
33
|
+
prompt = payload["prompt"].to_s
|
|
34
|
+
prompt = payload["user_prompt"].to_s if prompt.empty?
|
|
35
35
|
cwd = payload["cwd"].to_s
|
|
36
36
|
cwd = Dir.pwd if cwd.empty?
|
|
37
37
|
|
|
@@ -41,98 +41,26 @@ templates = File.expand_path("../templates", __dir__)
|
|
|
41
41
|
sid = SessionLedger.short_session_id(nil, session_id)
|
|
42
42
|
today = SessionLedger.day_id
|
|
43
43
|
|
|
44
|
-
# --- Extracted matching logic, shared by both the global and project store
|
|
45
|
-
# passes of job (f) below (mirrors hook-future-intent-check verbatim). ---
|
|
46
|
-
def future_intent_matches(store_root, message)
|
|
47
|
-
index_path = File.join(store_root, "INDEX.md")
|
|
48
|
-
return [] unless File.exist?(index_path)
|
|
49
|
-
|
|
50
|
-
lines = File.readlines(index_path)
|
|
51
|
-
future_dirs = []
|
|
52
|
-
section = nil
|
|
53
|
-
|
|
54
|
-
lines.each do |line|
|
|
55
|
-
section = :future if line.start_with?("## Future")
|
|
56
|
-
section = nil if line.start_with?("## ") && !line.start_with?("## Future")
|
|
57
|
-
next unless section == :future && line.strip.start_with?("- [")
|
|
58
|
-
|
|
59
|
-
future_dirs << $1 if line =~ /store\/([\w-]+)\//
|
|
60
|
-
end
|
|
61
|
-
return [] if future_dirs.empty?
|
|
62
|
-
|
|
63
|
-
matches = []
|
|
64
|
-
message_words = message.split(/\W+/).reject { |w| w.length < 4 }
|
|
65
|
-
|
|
66
|
-
future_dirs.each do |dir|
|
|
67
|
-
intent_path = File.join(store_root, "store", dir, "#{dir}.md")
|
|
68
|
-
next unless File.exist?(intent_path)
|
|
69
|
-
|
|
70
|
-
content = File.read(intent_path)
|
|
71
|
-
|
|
72
|
-
tags = []
|
|
73
|
-
tags = $1.split(",").map(&:strip).map(&:downcase) if content =~ /^tags:\s*\[([^\]]+)\]/
|
|
74
|
-
|
|
75
|
-
intent_name = ""
|
|
76
|
-
intent_name = $1.downcase if content =~ /^intent:\s*["']?(.+?)["']?\s*$/
|
|
77
|
-
|
|
78
|
-
keywords = (tags + intent_name.split(/\W+/).reject { |w| w.length < 4 }).map(&:downcase).uniq
|
|
79
|
-
|
|
80
|
-
matched_keywords = keywords.select { |kw| message.include?(kw) }
|
|
81
|
-
name_matches = message_words.select { |w| intent_name.include?(w) }
|
|
82
|
-
matched_keywords = (matched_keywords + name_matches).uniq
|
|
83
|
-
|
|
84
|
-
next if matched_keywords.empty?
|
|
85
|
-
|
|
86
|
-
index_line = lines.find { |l| l.include?(dir) }&.strip || "#{dir} - #{intent_name}"
|
|
87
|
-
matches << { "index_line" => index_line, "keywords" => matched_keywords }
|
|
88
|
-
end
|
|
89
|
-
matches
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# The store_root that carries the current project's own store, resolved from
|
|
93
|
-
# cwd through projects.yml, mirroring SessionLedger.project_slug's matching
|
|
94
|
-
# but returning nil (not "global") when nothing matches, since there is then
|
|
95
|
-
# no distinct second store to hint against.
|
|
96
|
-
def resolve_project_store_root(cwd, plastic_home)
|
|
97
|
-
projects_path = File.join(plastic_home, "projects.yml")
|
|
98
|
-
return nil unless File.exist?(projects_path)
|
|
99
|
-
|
|
100
|
-
data = begin
|
|
101
|
-
YAML.safe_load(File.read(projects_path))
|
|
102
|
-
rescue StandardError
|
|
103
|
-
nil
|
|
104
|
-
end
|
|
105
|
-
data = {} unless data.is_a?(Hash)
|
|
106
|
-
projects = data["projects"].is_a?(Hash) ? data["projects"] : {}
|
|
107
|
-
expanded_cwd = File.expand_path(cwd)
|
|
108
|
-
|
|
109
|
-
matches = projects.filter_map do |slug, info|
|
|
110
|
-
next unless info.is_a?(Hash) && slug.is_a?(String)
|
|
111
|
-
|
|
112
|
-
path = info["path"]
|
|
113
|
-
next unless path
|
|
114
|
-
|
|
115
|
-
root = File.expand_path(path)
|
|
116
|
-
next unless expanded_cwd == root || expanded_cwd.start_with?("#{root}#{File::SEPARATOR}")
|
|
117
|
-
|
|
118
|
-
[root.length, slug]
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
best = matches.max_by { |(length, _slug)| length }
|
|
122
|
-
best ? File.join(plastic_home, "projects", best[1]) : nil
|
|
123
|
-
end
|
|
124
|
-
|
|
125
44
|
def truncate(text, max)
|
|
126
45
|
return text if text.length <= max
|
|
127
46
|
|
|
128
47
|
"#{text[0, max - 3]}..."
|
|
129
48
|
end
|
|
130
49
|
|
|
131
|
-
# --- (a) tmp root + heartbeat,
|
|
50
|
+
# --- (a) tmp root + heartbeat, guarded on the pointer (spec D9) ------------
|
|
51
|
+
# Session start writes the `current` pointer; a capture with no pointer yet
|
|
52
|
+
# means session start never ran for this session at all (a `-p` print
|
|
53
|
+
# session, a resumed background job, an unregistered SessionStart hook), so
|
|
54
|
+
# neither the per-session dir nor its heartbeat is created. UserPromptSubmit
|
|
55
|
+
# fires before PostToolUse, so capture is the FIRST creator of every orphan
|
|
56
|
+
# `.tmp/<sid>/` this guard prevents; hook-record carries the same guard for
|
|
57
|
+
# its own (later) heartbeat write.
|
|
132
58
|
begin
|
|
133
|
-
SessionLedger.
|
|
134
|
-
|
|
135
|
-
|
|
59
|
+
if File.exist?(SessionLedger.pointer_path(store, sid))
|
|
60
|
+
SessionLedger.ensure_tmp_root(store)
|
|
61
|
+
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
|
|
62
|
+
File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
|
|
63
|
+
end
|
|
136
64
|
rescue StandardError
|
|
137
65
|
nil
|
|
138
66
|
end
|
|
@@ -150,11 +78,14 @@ rescue StandardError
|
|
|
150
78
|
end
|
|
151
79
|
|
|
152
80
|
# --- (c) pending checklist line -------------------------------------------
|
|
81
|
+
# Gated on SessionLedger.capture_worthy?(prompt) (spec D2), a pure predicate
|
|
82
|
+
# over the RAW prompt so it sees the true first character and multi-line
|
|
83
|
+
# shape; the line text itself is still the sanitized, truncated copy.
|
|
153
84
|
unless skip_day_ledger
|
|
154
85
|
begin
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
86
|
+
if SessionLedger.capture_worthy?(prompt)
|
|
87
|
+
project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
|
|
88
|
+
clean = truncate(SessionLedger.sanitize_summary(prompt), 120)
|
|
158
89
|
SessionLedger.open_day(store: store, day: today, templates: templates, author: sid)
|
|
159
90
|
line = SessionLedger.checklist_line(:pending, sid, project, clean)
|
|
160
91
|
SessionLedger.append_line(SessionLedger.checklist_path(store, today), line,
|
|
@@ -170,7 +101,7 @@ system_message = nil
|
|
|
170
101
|
|
|
171
102
|
# --- (d) "continue" cockpit -------------------------------------------------
|
|
172
103
|
begin
|
|
173
|
-
if prompt.
|
|
104
|
+
if prompt.to_s.strip.downcase == "continue"
|
|
174
105
|
dashboard = File.expand_path("dashboard.rb", __dir__)
|
|
175
106
|
if File.exist?(dashboard)
|
|
176
107
|
cockpit, _err, status = Open3.capture3({ "RUBYOPT" => nil }, "ruby", dashboard, "continue")
|
|
@@ -211,25 +142,6 @@ rescue StandardError
|
|
|
211
142
|
nil
|
|
212
143
|
end
|
|
213
144
|
|
|
214
|
-
# --- (f) Future-intent hint, global store then the project's own store -----
|
|
215
|
-
begin
|
|
216
|
-
message = prompt.to_s.downcase
|
|
217
|
-
if message.strip.length >= 10 && message.strip != "continue"
|
|
218
|
-
matches = future_intent_matches(plastic_home, message)
|
|
219
|
-
project_root = resolve_project_store_root(cwd, plastic_home)
|
|
220
|
-
matches += future_intent_matches(project_root, message) if project_root
|
|
221
|
-
|
|
222
|
-
if matches.any?
|
|
223
|
-
parts = ["PLASTIC - Future intents related to this message:\n"]
|
|
224
|
-
matches.each { |m| parts << "#{m["index_line"]} (matched: #{m["keywords"].join(", ")})" }
|
|
225
|
-
parts << "\nConsider asking the user if they want to activate any of these, or note the connection."
|
|
226
|
-
context_parts << parts.join("\n")
|
|
227
|
-
end
|
|
228
|
-
end
|
|
229
|
-
rescue StandardError
|
|
230
|
-
nil
|
|
231
|
-
end
|
|
232
|
-
|
|
233
145
|
exit 0 if context_parts.empty?
|
|
234
146
|
|
|
235
147
|
payload_out = {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# hook-message-display (intent 316a, O4): the MessageDisplay hook CLI. Reads
|
|
6
|
+
# the harness's per-chunk JSON payload from stdin, hands it to MessageDisplay
|
|
7
|
+
# (the pure handler class), and prints the hookSpecificOutput envelope when a
|
|
8
|
+
# String comes back. Always exits 0, whatever happens — a raised exception
|
|
9
|
+
# here must never surface as a non-zero exit or stray stderr on an ordinary
|
|
10
|
+
# chunk of an ordinary message (matrix 31).
|
|
11
|
+
#
|
|
12
|
+
# This script is the one place in the O4 stack allowed to read ENV, the
|
|
13
|
+
# clock, or Dir.tmpdir: MessageDisplay itself takes tmp_root/plastic_home/
|
|
14
|
+
# color/now as constructor arguments and touches none of them directly.
|
|
15
|
+
#
|
|
16
|
+
# Claude adapter: Claude Code only; the core is harness-agnostic.
|
|
17
|
+
|
|
18
|
+
# 317a (B1): the requires sit inside their own rescue - a missing or broken
|
|
19
|
+
# lib file must never surface as a non-zero exit or stderr on a chunk of an
|
|
20
|
+
# ordinary message (LoadError is a ScriptError, outside StandardError).
|
|
21
|
+
begin
|
|
22
|
+
require "json"
|
|
23
|
+
require "time"
|
|
24
|
+
require "tmpdir"
|
|
25
|
+
require "yaml"
|
|
26
|
+
require_relative "lib/message_display"
|
|
27
|
+
rescue ScriptError, StandardError
|
|
28
|
+
exit 0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def color_enabled?(plastic_home)
|
|
32
|
+
return false unless ENV["NO_COLOR"].to_s.empty?
|
|
33
|
+
|
|
34
|
+
cfg_path = File.join(plastic_home, "config.yml")
|
|
35
|
+
return true unless File.exist?(cfg_path)
|
|
36
|
+
|
|
37
|
+
cfg = YAML.safe_load(File.read(cfg_path))
|
|
38
|
+
display = cfg.is_a?(Hash) ? cfg["display"] : nil
|
|
39
|
+
return true unless display.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
display.fetch("ansi_screen", true) != false
|
|
42
|
+
rescue StandardError
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
raw = $stdin.tty? ? "" : $stdin.read
|
|
48
|
+
payload = raw.to_s.strip.empty? ? nil : JSON.parse(raw)
|
|
49
|
+
|
|
50
|
+
if payload.is_a?(Hash)
|
|
51
|
+
tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
|
|
52
|
+
plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
|
|
53
|
+
|
|
54
|
+
# PLASTIC_HOOK_TRACE=<file> (331a1): opt-in, off by default, one JSON
|
|
55
|
+
# object appended per chunk. This script is the only place allowed to
|
|
56
|
+
# read the environment; MessageDisplay takes the sink as an argument.
|
|
57
|
+
trace_path = ENV["PLASTIC_HOOK_TRACE"].to_s
|
|
58
|
+
trace = trace_path.empty? ? nil : MessageDisplay.file_trace(trace_path)
|
|
59
|
+
|
|
60
|
+
handler = MessageDisplay.new(
|
|
61
|
+
tmp_root: tmp_root,
|
|
62
|
+
plastic_home: plastic_home,
|
|
63
|
+
color: color_enabled?(plastic_home),
|
|
64
|
+
now: Time.now,
|
|
65
|
+
trace: trace,
|
|
66
|
+
)
|
|
67
|
+
result = handler.handle(payload)
|
|
68
|
+
|
|
69
|
+
if result.is_a?(String)
|
|
70
|
+
puts JSON.generate(
|
|
71
|
+
"hookSpecificOutput" => {
|
|
72
|
+
"hookEventName" => "MessageDisplay",
|
|
73
|
+
"displayContent" => result,
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
rescue StandardError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
exit 0
|
package/scripts/hook-record
CHANGED
|
@@ -135,13 +135,21 @@ unless skip_day_ledger
|
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
# --- (d) heartbeat,
|
|
138
|
+
# --- (d) heartbeat, guarded on the pointer (spec D9) -----------------------
|
|
139
|
+
# Session start writes the `current` pointer; with no pointer at all, session
|
|
140
|
+
# start never ran for this session (a `-p` print session, a resumed
|
|
141
|
+
# background job, an unregistered SessionStart hook), so neither the
|
|
142
|
+
# per-session dir nor its heartbeat is created here -- guarding only the
|
|
143
|
+
# write would still leave an empty directory that heartbeat_age's mtime
|
|
144
|
+
# fallback reports as an orphan just the same.
|
|
139
145
|
begin
|
|
140
146
|
store ||= File.join(plastic_home, "store")
|
|
141
147
|
sid ||= SessionLedger.short_session_id(nil, session_id)
|
|
142
|
-
SessionLedger.
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
if File.exist?(SessionLedger.pointer_path(store, sid))
|
|
149
|
+
SessionLedger.ensure_tmp_root(store)
|
|
150
|
+
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
|
|
151
|
+
File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
|
|
152
|
+
end
|
|
145
153
|
rescue StandardError
|
|
146
154
|
nil
|
|
147
155
|
end
|
|
@@ -18,6 +18,24 @@ require_relative "lib/day_summary"
|
|
|
18
18
|
index_path, plastic_home, mode, plugin_root = ARGV
|
|
19
19
|
exit 0 unless index_path && plastic_home && mode
|
|
20
20
|
|
|
21
|
+
# --- session id: the stdin payload's session_id, then the env var, then the
|
|
22
|
+
# pid (spec 298 D1, row G, spec D4). Guarded on $stdin.tty? so a human running
|
|
23
|
+
# this hook by hand at a real terminal never blocks on a read that never gets
|
|
24
|
+
# an EOF; every real harness invocation pipes the SessionStart JSON payload,
|
|
25
|
+
# never attaches a tty. Malformed or empty stdin (or no payload id) falls
|
|
26
|
+
# through to the same env-var-then-pid chain this always had.
|
|
27
|
+
stdin_payload = begin
|
|
28
|
+
if $stdin.tty?
|
|
29
|
+
nil
|
|
30
|
+
else
|
|
31
|
+
raw = $stdin.read
|
|
32
|
+
raw && !raw.strip.empty? ? JSON.parse(raw) : nil
|
|
33
|
+
end
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
payload_session_id = stdin_payload.is_a?(Hash) ? stdin_payload["session_id"].to_s : ""
|
|
38
|
+
|
|
21
39
|
# Plastic home and the store are two different paths (intent 231). The shim passes
|
|
22
40
|
# home (~/.plastic) as argument 2; the store lives one level below it. Compose the
|
|
23
41
|
# store exactly once here, so no later line re-derives it and no path can gain a
|
|
@@ -361,7 +379,11 @@ begin
|
|
|
361
379
|
author = "session" if author.empty?
|
|
362
380
|
SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
|
|
363
381
|
|
|
364
|
-
session =
|
|
382
|
+
session = if !payload_session_id.empty?
|
|
383
|
+
payload_session_id
|
|
384
|
+
else
|
|
385
|
+
ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
|
|
386
|
+
end
|
|
365
387
|
sid = SessionLedger.short_session_id(nil, session)
|
|
366
388
|
SessionLedger.ensure_tmp_root(store_dir)
|
|
367
389
|
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# index-projection - the CLI over IndexProjection (intent 337, G4): prints
|
|
6
|
+
# the drift between INDEX.md and the intent ledgers for one store root
|
|
7
|
+
# (INDEX.md plus a store/ directory), exits non-zero when any drift exists.
|
|
8
|
+
# --write renders INDEX.md's four status sections from the projection
|
|
9
|
+
# through AtomicWrite; every other byte, including ## Clusters and
|
|
10
|
+
# ## Relocated, is untouched.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# index-projection <store_root> [--write]
|
|
14
|
+
#
|
|
15
|
+
# Exit codes:
|
|
16
|
+
# 0 - no drift (or, with --write, the write landed cleanly)
|
|
17
|
+
# 1 - drift exists (a real terminal-ledger conflict, an INDEX entry with
|
|
18
|
+
# no directory, or a directory INDEX does not list)
|
|
19
|
+
# 2 - usage: no argument, or the path is not a store root
|
|
20
|
+
|
|
21
|
+
require_relative "lib/index_projection"
|
|
22
|
+
|
|
23
|
+
module IndexProjectionCli
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def usage
|
|
27
|
+
warn "Usage: index-projection <store_root> [--write]"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def main(argv)
|
|
31
|
+
args = argv.dup
|
|
32
|
+
write = !!args.delete("--write")
|
|
33
|
+
|
|
34
|
+
root = args.shift
|
|
35
|
+
unless root && Dir.exist?(root)
|
|
36
|
+
warn "index-projection: #{root.inspect} is not a store root"
|
|
37
|
+
usage
|
|
38
|
+
return 2
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
index_path = File.join(root, "INDEX.md")
|
|
42
|
+
store_dir = File.join(root, "store")
|
|
43
|
+
unless File.exist?(index_path)
|
|
44
|
+
warn "index-projection: no INDEX.md at #{index_path}"
|
|
45
|
+
return 2
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if write
|
|
49
|
+
result = IndexProjection.write(store_dir, index_path: index_path)
|
|
50
|
+
unless result[:ok]
|
|
51
|
+
warn "index-projection: #{result[:error]}"
|
|
52
|
+
return 2
|
|
53
|
+
end
|
|
54
|
+
puts "index-projection: wrote #{index_path} (#{result[:moved].length} moved)"
|
|
55
|
+
return 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
analysis = IndexProjection.analyze(store_dir, index_path: index_path)
|
|
59
|
+
print_report(analysis)
|
|
60
|
+
|
|
61
|
+
(analysis[:drift].empty? && analysis[:index_only].empty? && analysis[:directory_only].empty?) ? 0 : 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def print_report(analysis)
|
|
65
|
+
puts "Drift (#{analysis[:drift].length}):"
|
|
66
|
+
analysis[:drift].each { |r| puts " #{r[:id]}: INDEX=#{r[:index_status]} ledger=#{r[:ledger_status]}" }
|
|
67
|
+
puts "INDEX-only (#{analysis[:index_only].length}):"
|
|
68
|
+
analysis[:index_only].each { |r| puts " #{r[:id]} (#{r[:index_status]})" }
|
|
69
|
+
puts "Directory-only (#{analysis[:directory_only].length}):"
|
|
70
|
+
analysis[:directory_only].each { |r| puts " #{r[:id]}" }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
exit(IndexProjectionCli.main(ARGV)) if $PROGRAM_NAME == __FILE__
|