@zalom/plastic 2.0.0-alpha.9 → 2.0.1
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/PLASTIC.md +13 -136
- package/README.md +357 -133
- package/agents/plastic-enforcer.md +20 -15
- package/agents/plastic-executor.md +15 -4
- package/agents/plastic-node-research.md +30 -0
- package/agents/plastic-node-verify.md +28 -0
- package/agents/plastic-node-work.md +33 -0
- package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
- package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
- package/assets/plastic-logo.svg +1 -0
- package/bin/crap +4 -0
- package/bin/lib/context_budget.rb +43 -6
- package/bin/lib/skill_census.rb +839 -0
- package/bin/plastic +6 -0
- package/bin/plastic-skill-census +114 -0
- package/bin/test +24 -4
- package/bin/verify-change +345 -0
- package/config_asks.yml +4 -4
- package/deprecations.yml +1 -1
- package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
- package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
- package/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
- package/docs/help/human-report-contract.md +152 -0
- package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
- package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +11 -13
- package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
- package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
- package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
- package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
- package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +55 -2
- package/hooks/session-start +5 -1
- package/hooks/statusline +32 -27
- package/hooks/stop +5 -0
- package/package.json +5 -3
- package/scripts/append-ledger +2 -1
- package/scripts/dashboard.rb +267 -16
- package/scripts/day-summary +2 -1
- package/scripts/doctor.rb +680 -39
- package/scripts/end-intent +153 -30
- package/scripts/exec-worktree +5 -5
- package/scripts/file-session-intent +2 -1
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +24 -123
- package/scripts/hook-close +2 -1
- package/scripts/hook-message-display +7 -0
- package/scripts/hook-record +24 -16
- package/scripts/hook-savepoint +27 -3
- package/scripts/hook-session-start +359 -321
- package/scripts/hook-stop +58 -0
- package/scripts/index-projection +74 -0
- package/scripts/insight-append +17 -4
- package/scripts/install.rb +9 -7
- package/scripts/lib/action_graph_shim.rb +279 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +63 -25
- package/scripts/lib/arm.rb +48 -83
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/backup.rb +65 -0
- package/scripts/lib/cli/command.rb +92 -0
- package/scripts/lib/cli/commands/auto.rb +18 -0
- package/scripts/lib/cli/commands/auto_brief.rb +44 -0
- package/scripts/lib/cli/commands/auto_lock.rb +99 -0
- package/scripts/lib/cli/commands/auto_report.rb +50 -0
- package/scripts/lib/cli/commands/auto_take.rb +31 -0
- package/scripts/lib/cli/commands/backup.rb +43 -0
- package/scripts/lib/cli/commands/checkout.rb +25 -0
- package/scripts/lib/cli/commands/continue.rb +66 -0
- package/scripts/lib/cli/commands/doctor.rb +81 -0
- package/scripts/lib/cli/commands/feedback.rb +40 -0
- package/scripts/lib/cli/commands/help.rb +69 -0
- package/scripts/lib/cli/commands/hook.rb +32 -0
- package/scripts/lib/cli/commands/index.rb +23 -0
- package/scripts/lib/cli/commands/install.rb +21 -0
- package/scripts/lib/cli/commands/installer_verb.rb +37 -0
- package/scripts/lib/cli/commands/intent.rb +19 -0
- package/scripts/lib/cli/commands/intent_answer.rb +37 -0
- package/scripts/lib/cli/commands/intent_command.rb +53 -0
- package/scripts/lib/cli/commands/intent_end.rb +62 -0
- package/scripts/lib/cli/commands/intent_new.rb +62 -0
- package/scripts/lib/cli/commands/intent_note.rb +43 -0
- package/scripts/lib/cli/commands/intent_rule.rb +36 -0
- package/scripts/lib/cli/commands/intent_show.rb +32 -0
- package/scripts/lib/cli/commands/intent_spec.rb +44 -0
- package/scripts/lib/cli/commands/intent_step.rb +71 -0
- package/scripts/lib/cli/commands/intent_verify.rb +26 -0
- package/scripts/lib/cli/commands/migrate.rb +16 -0
- package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
- package/scripts/lib/cli/commands/next.rb +54 -0
- package/scripts/lib/cli/commands/project.rb +19 -0
- package/scripts/lib/cli/commands/project_links.rb +42 -0
- package/scripts/lib/cli/commands/project_list.rb +20 -0
- package/scripts/lib/cli/commands/project_new.rb +72 -0
- package/scripts/lib/cli/commands/query.rb +31 -0
- package/scripts/lib/cli/commands/render.rb +33 -0
- package/scripts/lib/cli/commands/roadmap.rb +19 -0
- package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
- package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
- package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
- package/scripts/lib/cli/commands/roadmap_next.rb +70 -0
- package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
- package/scripts/lib/cli/commands/rollback.rb +20 -0
- package/scripts/lib/cli/commands/search.rb +60 -0
- package/scripts/lib/cli/commands/session.rb +18 -0
- package/scripts/lib/cli/commands/session_commit.rb +42 -0
- package/scripts/lib/cli/commands/session_handoff.rb +34 -0
- package/scripts/lib/cli/commands/session_summary.rb +35 -0
- package/scripts/lib/cli/commands/status.rb +68 -0
- package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
- package/scripts/lib/cli/commands/sync.rb +46 -0
- package/scripts/lib/cli/commands/uninstall.rb +20 -0
- package/scripts/lib/cli/commands/update.rb +20 -0
- package/scripts/lib/cli/commands/version.rb +53 -0
- package/scripts/lib/cli/frontier.rb +86 -0
- package/scripts/lib/cli/intent_progress.rb +36 -0
- package/scripts/lib/cli/legacy.rb +80 -0
- package/scripts/lib/cli/output.rb +137 -0
- package/scripts/lib/cli/scope.rb +134 -0
- package/scripts/lib/cli/table.rb +65 -0
- package/scripts/lib/cli.rb +94 -0
- package/scripts/lib/codex_adapter.rb +198 -0
- package/scripts/lib/compact_instructions.rb +13 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/data_boundary.rb +132 -0
- package/scripts/lib/day_summary.rb +23 -14
- package/scripts/lib/doctor_core.rb +114 -38
- package/scripts/lib/doctor_session_ledger.rb +5 -53
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/feedback_report.rb +1 -1
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +409 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +413 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/handoff.rb +40 -13
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +27 -3
- package/scripts/lib/hook_replay.rb +229 -0
- package/scripts/lib/index_entry.rb +62 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +531 -71
- package/scripts/lib/intent_screen.rb +4 -4
- package/scripts/lib/intent_screen_ansi.rb +73 -12
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +10 -11
- package/scripts/lib/message_display.rb +350 -54
- package/scripts/lib/meter_watch.rb +185 -0
- package/scripts/lib/node_file.rb +234 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_input.rb +913 -0
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +386 -0
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/node_return.rb +204 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/preflight.rb +4 -6
- package/scripts/lib/project_config.rb +46 -0
- package/scripts/lib/project_validator.rb +3 -2
- package/scripts/lib/qmd_sync.rb +8 -7
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/reference_archive.rb +45 -0
- package/scripts/lib/release_guard.rb +18 -0
- package/scripts/lib/report_screen.rb +1371 -47
- package/scripts/lib/rlm/corpus.rb +13 -0
- package/scripts/lib/rlm/probe.rb +29 -0
- package/scripts/lib/rlm/query.rb +22 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +158 -8
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +64 -14
- package/scripts/lib/runner_absorb.rb +703 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +525 -0
- package/scripts/lib/runner_policy.rb +191 -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/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +141 -19
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/screen_paint.rb +365 -28
- 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/search_index.rb +55 -0
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +25 -18
- package/scripts/lib/session_ledger.rb +48 -4
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/sqlite.rb +22 -0
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/store_discovery.rb +7 -6
- package/scripts/lib/store_layout.rb +54 -0
- package/scripts/lib/store_provisioning.rb +2 -1
- package/scripts/lib/store_sync.rb +85 -0
- package/scripts/lib/stores_move.rb +93 -0
- package/scripts/lib/verify_intent.rb +36 -8
- package/scripts/lib/version_number.rb +48 -0
- package/scripts/lib/work_graph.rb +59 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/lib/worktree.rb +27 -32
- package/scripts/lib/worktree_sweep.rb +6 -5
- package/scripts/link-suggest +2 -1
- package/scripts/meter-watch +57 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +4 -13
- package/scripts/node-input +92 -0
- package/scripts/node-run +225 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +42 -33
- package/scripts/promote-session-item +3 -2
- package/scripts/read-config +53 -9
- package/scripts/ready-set +126 -0
- package/scripts/release-check +123 -0
- package/scripts/report-screen +177 -16
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +581 -0
- package/scripts/savepoint-note +11 -9
- package/scripts/session-commit +2 -1
- package/scripts/session-usage +56 -0
- package/scripts/skill-lint +115 -6
- package/scripts/spawn-preamble +2 -2
- package/scripts/update.rb +25 -4
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +3 -2
- package/scripts/write-handoff +2 -1
- package/templates/agents.md +7 -7
- package/templates/config.yml +16 -9
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/index.md +1 -1
- package/templates/intent-screen.md +1 -1
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +13 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +8 -3
- package/templates/project.yml +1 -1
- package/templates/render.css +10 -0
- 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 +1 -1
- package/templates/roadmap.md +13 -0
- package/bin/plastic.js +0 -70
- package/scripts/lib/bridge.rb +0 -116
- package/skills/agent-advisor/SKILL.md +0 -92
- package/skills/auto/SKILL.md +0 -296
- package/skills/auto/evals/evals.json +0 -255
- package/skills/auto/references/end-tail.md +0 -66
- package/skills/auto/references/human-report-contract.md +0 -78
- package/skills/conventions/SKILL.md +0 -29
- package/skills/dashboard/SKILL.md +0 -169
- package/skills/dashboard/evals/evals.json +0 -38
- package/skills/dashboard/references/classification.md +0 -22
- package/skills/dashboard/templates/dashboard-global.md +0 -20
- package/skills/dashboard/templates/dashboard-project.md +0 -19
- package/skills/direct/SKILL.md +0 -66
- package/skills/direct/references/request-signals.md +0 -59
- package/skills/doctor/SKILL.md +0 -299
- package/skills/doctor/report.md +0 -102
- package/skills/feedback/SKILL.md +0 -98
- package/skills/feedback/references/transport-and-privacy.md +0 -65
- package/skills/feedback/report.md +0 -36
- package/skills/install/SKILL.md +0 -217
- package/skills/intent-continuing/SKILL.md +0 -154
- package/skills/intent-continuing/references/board-fill.md +0 -43
- package/skills/intent-continuing/references/boarding-matrix.md +0 -34
- package/skills/intent-continuing/references/context-management.md +0 -28
- package/skills/intent-continuing/references/liveness-ranking.md +0 -57
- package/skills/intent-creating/SKILL.md +0 -164
- package/skills/intent-creating/evals/evals.json +0 -72
- package/skills/intent-creating/references/lifecycle.md +0 -81
- package/skills/intent-creating/references/wikilinks.md +0 -8
- package/skills/intent-ending/SKILL.md +0 -176
- package/skills/intent-ending/evals/evals.json +0 -74
- package/skills/intent-executing/SKILL.md +0 -170
- package/skills/intent-executing/evals/evals.json +0 -66
- package/skills/intent-executing/implementer-prompt.md +0 -42
- package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
- package/skills/intent-speccing/SKILL.md +0 -130
- package/skills/intent-speccing/evals/evals.json +0 -126
- package/skills/intent-speccing/references/design-principles.md +0 -44
- package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
- package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
- package/skills/project-creating/SKILL.md +0 -162
- package/skills/project-creating/references/hubs-projects.md +0 -55
- package/skills/project-creating/references/project-scaffolding.md +0 -97
- package/skills/releasing/SKILL.md +0 -337
- package/skills/releasing/references/deprecations.md +0 -60
- package/skills/releasing/references/promotion-and-tagging.md +0 -66
- package/skills/releasing/references/release-lines.md +0 -105
- package/skills/roadmap/SKILL.md +0 -64
- package/skills/roadmap/references/file-format.md +0 -124
- package/skills/roadmap/references/operations.md +0 -112
- package/skills/rollback/SKILL.md +0 -91
- package/skills/tutorial/SKILL.md +0 -65
- package/skills/tutorial/evals/evals.json +0 -186
- package/skills/uninstall/SKILL.md +0 -75
- package/skills/update/SKILL.md +0 -126
- /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
- /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
- /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
- /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
|
@@ -0,0 +1,229 @@
|
|
|
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, lead_until_engaged: false)
|
|
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
|
+
payloads = chunks.each_with_index.map do |delta, i|
|
|
93
|
+
{
|
|
94
|
+
"session_id" => session_id, "message_id" => message_id, "index" => i,
|
|
95
|
+
"final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
|
|
96
|
+
"hook_event_name" => "MessageDisplay",
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
results = Array.new(chunks.length)
|
|
100
|
+
|
|
101
|
+
# `lead_until_engaged` runs chunks one at a time until one engages, so
|
|
102
|
+
# the decision marker is on disk before the rest fire concurrently. A
|
|
103
|
+
# caller proving "no late passthrough once the decision is in place"
|
|
104
|
+
# then tests exactly that, instead of whether chunk 0's own process
|
|
105
|
+
# booted inside a later chunk's poll budget on a loaded machine.
|
|
106
|
+
lead = 0
|
|
107
|
+
if lead_until_engaged
|
|
108
|
+
payloads.each_with_index do |payload, i|
|
|
109
|
+
out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
|
|
110
|
+
results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
|
|
111
|
+
lead = i + 1
|
|
112
|
+
break unless out.to_s.empty?
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
threads = payloads.each_with_index.drop(lead).map do |payload, i|
|
|
117
|
+
Thread.new do
|
|
118
|
+
begin
|
|
119
|
+
delay = (i - lead) * gap
|
|
120
|
+
delay += (rand * gap / 2.0) if jitter
|
|
121
|
+
sleep(delay)
|
|
122
|
+
out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
|
|
123
|
+
results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
# A raise inside a thread body is invisible until join, and an
|
|
126
|
+
# unrescued one aborts `threads.each(&:join)` at the first dead
|
|
127
|
+
# thread: every later thread is then never joined and outlives the
|
|
128
|
+
# call, racing whatever the caller does next (typically removing
|
|
129
|
+
# the very tmp root those threads are still writing under). Report
|
|
130
|
+
# the failure as this chunk's own result instead, so the array is
|
|
131
|
+
# always complete, every thread is always joined, and a replay
|
|
132
|
+
# tells its caller what went wrong rather than throwing at it.
|
|
133
|
+
results[i] = { index: i, exitstatus: nil, stdout: "", stderr: e.message,
|
|
134
|
+
final: payload["final"] }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
threads.each { |thread| thread.join }
|
|
139
|
+
results
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Indices of the chunks that reached the terminal as raw Markdown: a
|
|
143
|
+
# non-final chunk that emitted nothing at all, after the engaging chunk.
|
|
144
|
+
# A chunk "passed through" when its stdout is empty; a chunk was
|
|
145
|
+
# "blanked" (correctly buffered, not shown raw) when its stdout contains
|
|
146
|
+
# `"displayContent":""`. Only chunks with an index greater than the
|
|
147
|
+
# engaging chunk's own index count - the engaging chunk is the first one
|
|
148
|
+
# (at or after `start_index`) whose stdout is non-empty, and chunks
|
|
149
|
+
# before it already reached the terminal live, verbatim, through the
|
|
150
|
+
# ordinary passthrough path (they were never candidates for buffering at
|
|
151
|
+
# all, so an empty stdout from one of them is not this defect).
|
|
152
|
+
def passthrough_indices(outs, start_index: 0)
|
|
153
|
+
engaging = outs.find { |o| o[:index] >= start_index && !o[:stdout].to_s.empty? }
|
|
154
|
+
return [] unless engaging
|
|
155
|
+
|
|
156
|
+
outs.select { |o| o[:index] > engaging[:index] && o[:final] != true && o[:stdout].to_s.empty? }
|
|
157
|
+
.map { |o| o[:index] }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def run_one(hook_path, payload, full_env, tmp_root, timeout)
|
|
161
|
+
return capture(hook_path, payload, full_env) unless timeout
|
|
162
|
+
|
|
163
|
+
run_bounded(hook_path, payload, full_env, tmp_root, timeout)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def capture(hook_path, payload, full_env)
|
|
167
|
+
out, err, status = Open3.capture3(full_env, hook_path, stdin_data: JSON.generate(payload))
|
|
168
|
+
[out, err, status.exitstatus]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Spawn directly (never Open3.capture3) so a timeout can actually kill the
|
|
172
|
+
# child, with stdin/stdout/stderr routed through scratch files under the
|
|
173
|
+
# caller's own tmp_root, and never pipes, so a stalled or oversized write can
|
|
174
|
+
# never deadlock the read side, and never anywhere outside tmp_root, so a
|
|
175
|
+
# bounded replay carries the same "writes only under the injected tmp
|
|
176
|
+
# root" guarantee as the unbounded path.
|
|
177
|
+
def run_bounded(hook_path, payload, full_env, tmp_root, timeout)
|
|
178
|
+
token = "#{Process.pid}-#{(Time.now.to_f * 1_000_000).to_i}-#{rand(1_000_000)}"
|
|
179
|
+
in_path = File.join(tmp_root, ".hook-replay-in-#{token}")
|
|
180
|
+
out_path = File.join(tmp_root, ".hook-replay-out-#{token}")
|
|
181
|
+
err_path = File.join(tmp_root, ".hook-replay-err-#{token}")
|
|
182
|
+
File.write(in_path, JSON.generate(payload))
|
|
183
|
+
|
|
184
|
+
pid = Process.spawn(full_env, hook_path, in: in_path, out: out_path, err: err_path)
|
|
185
|
+
exitstatus =
|
|
186
|
+
begin
|
|
187
|
+
Timeout.timeout(timeout) { Process.wait(pid) }
|
|
188
|
+
$?.exitstatus
|
|
189
|
+
rescue Timeout::Error
|
|
190
|
+
kill_and_reap(pid)
|
|
191
|
+
nil # nil exitstatus is the caller's signal that this chunk timed out
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
out = File.exist?(out_path) ? File.read(out_path) : ""
|
|
195
|
+
err = File.exist?(err_path) ? File.read(err_path) : ""
|
|
196
|
+
[out, err, exitstatus]
|
|
197
|
+
ensure
|
|
198
|
+
[in_path, out_path, err_path].each { |p| File.delete(p) if p && File.exist?(p) }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def kill_and_reap(pid)
|
|
202
|
+
Process.kill("KILL", pid)
|
|
203
|
+
rescue StandardError
|
|
204
|
+
nil
|
|
205
|
+
ensure
|
|
206
|
+
begin
|
|
207
|
+
Process.wait(pid)
|
|
208
|
+
rescue StandardError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The final chunk's parsed displayContent, or nil when it emitted nothing
|
|
214
|
+
# (no envelope at all: the message never engaged).
|
|
215
|
+
def final_display_content(outs)
|
|
216
|
+
final = outs.last
|
|
217
|
+
return nil if final[:stdout].to_s.empty?
|
|
218
|
+
|
|
219
|
+
JSON.parse(final[:stdout]).dig("hookSpecificOutput", "displayContent")
|
|
220
|
+
rescue JSON::ParserError
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# True when any chunk's spawn hit its timeout (run_bounded's nil-exitstatus
|
|
225
|
+
# signal). A replay made with no `timeout:` never reports true.
|
|
226
|
+
def timed_out?(outs)
|
|
227
|
+
outs.any? { |o| o[:exitstatus].nil? }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# IndexEntry - the shared INDEX.md entry matcher and Active check (intent 188,
|
|
5
|
+
# D12/D13; moved off the retired shared-helpers module by intent 344, D2).
|
|
6
|
+
#
|
|
7
|
+
# ONE definition site for the "- [ID <sep> Title](link)" shape both `active?`
|
|
8
|
+
# (below) and `scripts/end-intent`'s own INDEX-move parser depend on, so the
|
|
9
|
+
# two regexes can never drift apart. Accepts a real em dash (U+2014) OR a
|
|
10
|
+
# plain hyphen as the id/title separator on READ; every WRITE still emits the
|
|
11
|
+
# real em dash (D10). The separator is built from the codepoint, not a
|
|
12
|
+
# literal byte, so this file stays em-dash free.
|
|
13
|
+
require_relative "atomic_write"
|
|
14
|
+
|
|
15
|
+
module IndexEntry
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
EM_DASH = "\u2014".freeze
|
|
19
|
+
ENTRY_RE = /\A- \[(\S+)\s+(?:#{Regexp.escape(EM_DASH)}|-)\s+(.*?)\]\(([^)]+)\)/.freeze
|
|
20
|
+
|
|
21
|
+
# Match `line` (already chomped) against the shared INDEX entry shape.
|
|
22
|
+
# Returns a MatchData (captures: 1 = id, 2 = title, 3 = link) or nil.
|
|
23
|
+
def match(line)
|
|
24
|
+
line.to_s.match(ENTRY_RE)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_active(index_path, dir_name:, title:)
|
|
28
|
+
id = dir_name.split("--").first
|
|
29
|
+
line = "- [#{id} #{EM_DASH} #{title}](store/#{dir_name}/#{dir_name}.md)\n"
|
|
30
|
+
text = File.read(index_path, encoding: "UTF-8")
|
|
31
|
+
AtomicWrite.write(index_path, text.sub(/^## Active\n/) { |heading| heading + line })
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True iff the intent is Active in its store's INDEX.md, which lives at the
|
|
35
|
+
# PARENT of the store/ dir. Non-raising: any failure (missing or unreadable
|
|
36
|
+
# INDEX, bad arg) returns false. `index_active_ids` is a pure-data test
|
|
37
|
+
# seam: when an Array of id strings is supplied, membership is checked
|
|
38
|
+
# against it directly with no file read.
|
|
39
|
+
def active?(intent_id, store:, index_active_ids: nil)
|
|
40
|
+
target = intent_id.to_s
|
|
41
|
+
return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
|
|
42
|
+
|
|
43
|
+
index = File.join(File.dirname(store.to_s), "INDEX.md")
|
|
44
|
+
return false unless File.exist?(index)
|
|
45
|
+
|
|
46
|
+
in_active = false
|
|
47
|
+
File.foreach(index) do |line|
|
|
48
|
+
stripped = line.chomp
|
|
49
|
+
if stripped == "## Active"
|
|
50
|
+
in_active = true
|
|
51
|
+
next
|
|
52
|
+
end
|
|
53
|
+
next unless in_active
|
|
54
|
+
break if stripped.start_with?("## ") # next section ends the Active block
|
|
55
|
+
m = match(stripped)
|
|
56
|
+
return true if m && m[1] == target
|
|
57
|
+
end
|
|
58
|
+
false
|
|
59
|
+
rescue StandardError
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
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, merged 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
|
package/scripts/lib/insights.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
|
|
4
4
|
# insights.rb - the blessed write path for an intent's `## Insights` section
|
|
5
|
-
# (intent 82). A
|
|
5
|
+
# (intent 82). A standalone library so the insight write path stays
|
|
6
6
|
# independent of the savepoint ledger (the spec forbids touching the ledger).
|
|
7
7
|
#
|
|
8
8
|
# Every entry leads with a fixed, machine-parseable prefix
|