@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.
Files changed (322) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +20 -15
  4. package/agents/plastic-executor.md +15 -4
  5. package/agents/plastic-node-research.md +30 -0
  6. package/agents/plastic-node-verify.md +28 -0
  7. package/agents/plastic-node-work.md +33 -0
  8. package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
  9. package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
  10. package/assets/plastic-logo.svg +1 -0
  11. package/bin/crap +4 -0
  12. package/bin/lib/context_budget.rb +43 -6
  13. package/bin/lib/skill_census.rb +839 -0
  14. package/bin/plastic +6 -0
  15. package/bin/plastic-skill-census +114 -0
  16. package/bin/test +24 -4
  17. package/bin/verify-change +345 -0
  18. package/config_asks.yml +4 -4
  19. package/deprecations.yml +1 -1
  20. package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
  21. package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
  22. package/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
  23. package/docs/help/human-report-contract.md +152 -0
  24. package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
  25. package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +11 -13
  26. package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
  27. package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
  28. package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
  29. package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
  30. package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
  31. package/hooks/call-budget +4 -0
  32. package/hooks/hooks.json +24 -0
  33. package/hooks/message-display +55 -2
  34. package/hooks/session-start +5 -1
  35. package/hooks/statusline +32 -27
  36. package/hooks/stop +5 -0
  37. package/package.json +5 -3
  38. package/scripts/append-ledger +2 -1
  39. package/scripts/dashboard.rb +267 -16
  40. package/scripts/day-summary +2 -1
  41. package/scripts/doctor.rb +680 -39
  42. package/scripts/end-intent +153 -30
  43. package/scripts/exec-worktree +5 -5
  44. package/scripts/file-session-intent +2 -1
  45. package/scripts/graph-measure +249 -0
  46. package/scripts/hook-call-budget +222 -0
  47. package/scripts/hook-capture +24 -123
  48. package/scripts/hook-close +2 -1
  49. package/scripts/hook-message-display +7 -0
  50. package/scripts/hook-record +24 -16
  51. package/scripts/hook-savepoint +27 -3
  52. package/scripts/hook-session-start +359 -321
  53. package/scripts/hook-stop +58 -0
  54. package/scripts/index-projection +74 -0
  55. package/scripts/insight-append +17 -4
  56. package/scripts/install.rb +9 -7
  57. package/scripts/lib/action_graph_shim.rb +279 -0
  58. package/scripts/lib/active_delivery.rb +105 -0
  59. package/scripts/lib/agent_models.rb +63 -25
  60. package/scripts/lib/arm.rb +48 -83
  61. package/scripts/lib/atomic_write.rb +31 -0
  62. package/scripts/lib/backup.rb +65 -0
  63. package/scripts/lib/cli/command.rb +92 -0
  64. package/scripts/lib/cli/commands/auto.rb +18 -0
  65. package/scripts/lib/cli/commands/auto_brief.rb +44 -0
  66. package/scripts/lib/cli/commands/auto_lock.rb +99 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +31 -0
  69. package/scripts/lib/cli/commands/backup.rb +43 -0
  70. package/scripts/lib/cli/commands/checkout.rb +25 -0
  71. package/scripts/lib/cli/commands/continue.rb +66 -0
  72. package/scripts/lib/cli/commands/doctor.rb +81 -0
  73. package/scripts/lib/cli/commands/feedback.rb +40 -0
  74. package/scripts/lib/cli/commands/help.rb +69 -0
  75. package/scripts/lib/cli/commands/hook.rb +32 -0
  76. package/scripts/lib/cli/commands/index.rb +23 -0
  77. package/scripts/lib/cli/commands/install.rb +21 -0
  78. package/scripts/lib/cli/commands/installer_verb.rb +37 -0
  79. package/scripts/lib/cli/commands/intent.rb +19 -0
  80. package/scripts/lib/cli/commands/intent_answer.rb +37 -0
  81. package/scripts/lib/cli/commands/intent_command.rb +53 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +62 -0
  83. package/scripts/lib/cli/commands/intent_new.rb +62 -0
  84. package/scripts/lib/cli/commands/intent_note.rb +43 -0
  85. package/scripts/lib/cli/commands/intent_rule.rb +36 -0
  86. package/scripts/lib/cli/commands/intent_show.rb +32 -0
  87. package/scripts/lib/cli/commands/intent_spec.rb +44 -0
  88. package/scripts/lib/cli/commands/intent_step.rb +71 -0
  89. package/scripts/lib/cli/commands/intent_verify.rb +26 -0
  90. package/scripts/lib/cli/commands/migrate.rb +16 -0
  91. package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
  92. package/scripts/lib/cli/commands/next.rb +54 -0
  93. package/scripts/lib/cli/commands/project.rb +19 -0
  94. package/scripts/lib/cli/commands/project_links.rb +42 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +72 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +33 -0
  99. package/scripts/lib/cli/commands/roadmap.rb +19 -0
  100. package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
  101. package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
  102. package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
  103. package/scripts/lib/cli/commands/roadmap_next.rb +70 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
  105. package/scripts/lib/cli/commands/rollback.rb +20 -0
  106. package/scripts/lib/cli/commands/search.rb +60 -0
  107. package/scripts/lib/cli/commands/session.rb +18 -0
  108. package/scripts/lib/cli/commands/session_commit.rb +42 -0
  109. package/scripts/lib/cli/commands/session_handoff.rb +34 -0
  110. package/scripts/lib/cli/commands/session_summary.rb +35 -0
  111. package/scripts/lib/cli/commands/status.rb +68 -0
  112. package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
  113. package/scripts/lib/cli/commands/sync.rb +46 -0
  114. package/scripts/lib/cli/commands/uninstall.rb +20 -0
  115. package/scripts/lib/cli/commands/update.rb +20 -0
  116. package/scripts/lib/cli/commands/version.rb +53 -0
  117. package/scripts/lib/cli/frontier.rb +86 -0
  118. package/scripts/lib/cli/intent_progress.rb +36 -0
  119. package/scripts/lib/cli/legacy.rb +80 -0
  120. package/scripts/lib/cli/output.rb +137 -0
  121. package/scripts/lib/cli/scope.rb +134 -0
  122. package/scripts/lib/cli/table.rb +65 -0
  123. package/scripts/lib/cli.rb +94 -0
  124. package/scripts/lib/codex_adapter.rb +198 -0
  125. package/scripts/lib/compact_instructions.rb +13 -5
  126. package/scripts/lib/core_integrity.rb +71 -0
  127. package/scripts/lib/dashboard_screen.rb +40 -0
  128. package/scripts/lib/data_boundary.rb +132 -0
  129. package/scripts/lib/day_summary.rb +23 -14
  130. package/scripts/lib/doctor_core.rb +114 -38
  131. package/scripts/lib/doctor_session_ledger.rb +5 -53
  132. package/scripts/lib/engine_permissions.rb +88 -0
  133. package/scripts/lib/exec_worktree.rb +24 -21
  134. package/scripts/lib/feedback_report.rb +1 -1
  135. package/scripts/lib/graph_edges.rb +137 -0
  136. package/scripts/lib/graph_file.rb +246 -0
  137. package/scripts/lib/graph_measure.rb +645 -0
  138. package/scripts/lib/graph_measure_budget.rb +409 -0
  139. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  140. package/scripts/lib/graph_measure_models.rb +413 -0
  141. package/scripts/lib/graph_measure_report.rb +532 -0
  142. package/scripts/lib/graph_tree.rb +98 -0
  143. package/scripts/lib/guarded_append.rb +155 -0
  144. package/scripts/lib/handoff.rb +40 -13
  145. package/scripts/lib/harness_adapter.rb +184 -0
  146. package/scripts/lib/hook_registry.rb +27 -3
  147. package/scripts/lib/hook_replay.rb +229 -0
  148. package/scripts/lib/index_entry.rb +62 -0
  149. package/scripts/lib/index_projection.rb +201 -0
  150. package/scripts/lib/insights.rb +1 -1
  151. package/scripts/lib/installer_core.rb +531 -71
  152. package/scripts/lib/intent_screen.rb +4 -4
  153. package/scripts/lib/intent_screen_ansi.rb +73 -12
  154. package/scripts/lib/intent_validator.rb +2 -2
  155. package/scripts/lib/lock.rb +10 -11
  156. package/scripts/lib/message_display.rb +350 -54
  157. package/scripts/lib/meter_watch.rb +185 -0
  158. package/scripts/lib/node_file.rb +234 -0
  159. package/scripts/lib/node_ids.rb +99 -0
  160. package/scripts/lib/node_input.rb +913 -0
  161. package/scripts/lib/node_input_compatibility.rb +62 -0
  162. package/scripts/lib/node_ledger.rb +386 -0
  163. package/scripts/lib/node_progress.rb +153 -0
  164. package/scripts/lib/node_return.rb +204 -0
  165. package/scripts/lib/node_worktree.rb +337 -0
  166. package/scripts/lib/outcome_report.rb +440 -0
  167. package/scripts/lib/preflight.rb +4 -6
  168. package/scripts/lib/project_config.rb +46 -0
  169. package/scripts/lib/project_validator.rb +3 -2
  170. package/scripts/lib/qmd_sync.rb +8 -7
  171. package/scripts/lib/ready_set.rb +462 -0
  172. package/scripts/lib/reference_archive.rb +45 -0
  173. package/scripts/lib/release_guard.rb +18 -0
  174. package/scripts/lib/report_screen.rb +1371 -47
  175. package/scripts/lib/rlm/corpus.rb +13 -0
  176. package/scripts/lib/rlm/probe.rb +29 -0
  177. package/scripts/lib/rlm/query.rb +22 -0
  178. package/scripts/lib/roadmap_graph.rb +210 -0
  179. package/scripts/lib/roadmap_migration.rb +95 -0
  180. package/scripts/lib/roadmap_queue.rb +158 -8
  181. package/scripts/lib/roadmap_render.rb +150 -0
  182. package/scripts/lib/roadmap_savepoint.rb +64 -14
  183. package/scripts/lib/runner_absorb.rb +703 -0
  184. package/scripts/lib/runner_answer.rb +206 -0
  185. package/scripts/lib/runner_core.rb +194 -0
  186. package/scripts/lib/runner_dispatch.rb +525 -0
  187. package/scripts/lib/runner_policy.rb +191 -0
  188. package/scripts/lib/runner_proposals.rb +275 -0
  189. package/scripts/lib/runner_rewind.rb +201 -0
  190. package/scripts/lib/runner_sweep.rb +231 -0
  191. package/scripts/lib/runner_until_empty.rb +252 -0
  192. package/scripts/lib/runner_watch.rb +389 -0
  193. package/scripts/lib/savepoint.rb +141 -19
  194. package/scripts/lib/scaffold_intent.rb +6 -3
  195. package/scripts/lib/screen_paint.rb +365 -28
  196. package/scripts/lib/screens/dashboard.rb +20 -0
  197. package/scripts/lib/screens/plan.rb +18 -0
  198. package/scripts/lib/screens/roadmap.rb +15 -0
  199. package/scripts/lib/search_index.rb +55 -0
  200. package/scripts/lib/session_close.rb +30 -28
  201. package/scripts/lib/session_git.rb +25 -18
  202. package/scripts/lib/session_ledger.rb +48 -4
  203. package/scripts/lib/session_usage.rb +190 -0
  204. package/scripts/lib/sqlite.rb +22 -0
  205. package/scripts/lib/stop_gate.rb +95 -0
  206. package/scripts/lib/store_discovery.rb +7 -6
  207. package/scripts/lib/store_layout.rb +54 -0
  208. package/scripts/lib/store_provisioning.rb +2 -1
  209. package/scripts/lib/store_sync.rb +85 -0
  210. package/scripts/lib/stores_move.rb +93 -0
  211. package/scripts/lib/verify_intent.rb +36 -8
  212. package/scripts/lib/version_number.rb +48 -0
  213. package/scripts/lib/work_graph.rb +59 -0
  214. package/scripts/lib/work_graph_validator.rb +201 -0
  215. package/scripts/lib/worktree.rb +27 -32
  216. package/scripts/lib/worktree_sweep.rb +6 -5
  217. package/scripts/link-suggest +2 -1
  218. package/scripts/meter-watch +57 -0
  219. package/scripts/migrate-to-global +1 -1
  220. package/scripts/new-intent +4 -13
  221. package/scripts/node-input +92 -0
  222. package/scripts/node-run +225 -0
  223. package/scripts/node-transition +291 -0
  224. package/scripts/outcome-report +74 -0
  225. package/scripts/plastic-lock +42 -33
  226. package/scripts/promote-session-item +3 -2
  227. package/scripts/read-config +53 -9
  228. package/scripts/ready-set +126 -0
  229. package/scripts/release-check +123 -0
  230. package/scripts/report-screen +177 -16
  231. package/scripts/roadmap-graph +119 -0
  232. package/scripts/roadmap-savepoint +7 -0
  233. package/scripts/runner +581 -0
  234. package/scripts/savepoint-note +11 -9
  235. package/scripts/session-commit +2 -1
  236. package/scripts/session-usage +56 -0
  237. package/scripts/skill-lint +115 -6
  238. package/scripts/spawn-preamble +2 -2
  239. package/scripts/update.rb +25 -4
  240. package/scripts/validate-work-graph +39 -0
  241. package/scripts/verify-intent +3 -2
  242. package/scripts/write-handoff +2 -1
  243. package/templates/agents.md +7 -7
  244. package/templates/config.yml +16 -9
  245. package/templates/dashboard-screen.md +22 -0
  246. package/templates/display-fixture.md +21 -0
  247. package/templates/graph.md +16 -0
  248. package/templates/index.md +1 -1
  249. package/templates/intent-screen.md +1 -1
  250. package/templates/node-decision.md +11 -0
  251. package/templates/node-research.md +13 -0
  252. package/templates/node-verify.md +13 -0
  253. package/templates/node-work.md +22 -0
  254. package/templates/outcome.md +8 -3
  255. package/templates/project.yml +1 -1
  256. package/templates/render.css +10 -0
  257. package/templates/report-plan.md +15 -0
  258. package/templates/report-roadmap-delivered.md +10 -0
  259. package/templates/report-roadmap-plan.md +9 -0
  260. package/templates/report-roadmap-state.md +9 -0
  261. package/templates/report-state.md +1 -1
  262. package/templates/roadmap.md +13 -0
  263. package/bin/plastic.js +0 -70
  264. package/scripts/lib/bridge.rb +0 -116
  265. package/skills/agent-advisor/SKILL.md +0 -92
  266. package/skills/auto/SKILL.md +0 -296
  267. package/skills/auto/evals/evals.json +0 -255
  268. package/skills/auto/references/end-tail.md +0 -66
  269. package/skills/auto/references/human-report-contract.md +0 -78
  270. package/skills/conventions/SKILL.md +0 -29
  271. package/skills/dashboard/SKILL.md +0 -169
  272. package/skills/dashboard/evals/evals.json +0 -38
  273. package/skills/dashboard/references/classification.md +0 -22
  274. package/skills/dashboard/templates/dashboard-global.md +0 -20
  275. package/skills/dashboard/templates/dashboard-project.md +0 -19
  276. package/skills/direct/SKILL.md +0 -66
  277. package/skills/direct/references/request-signals.md +0 -59
  278. package/skills/doctor/SKILL.md +0 -299
  279. package/skills/doctor/report.md +0 -102
  280. package/skills/feedback/SKILL.md +0 -98
  281. package/skills/feedback/references/transport-and-privacy.md +0 -65
  282. package/skills/feedback/report.md +0 -36
  283. package/skills/install/SKILL.md +0 -217
  284. package/skills/intent-continuing/SKILL.md +0 -154
  285. package/skills/intent-continuing/references/board-fill.md +0 -43
  286. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  287. package/skills/intent-continuing/references/context-management.md +0 -28
  288. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  289. package/skills/intent-creating/SKILL.md +0 -164
  290. package/skills/intent-creating/evals/evals.json +0 -72
  291. package/skills/intent-creating/references/lifecycle.md +0 -81
  292. package/skills/intent-creating/references/wikilinks.md +0 -8
  293. package/skills/intent-ending/SKILL.md +0 -176
  294. package/skills/intent-ending/evals/evals.json +0 -74
  295. package/skills/intent-executing/SKILL.md +0 -170
  296. package/skills/intent-executing/evals/evals.json +0 -66
  297. package/skills/intent-executing/implementer-prompt.md +0 -42
  298. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  299. package/skills/intent-speccing/SKILL.md +0 -130
  300. package/skills/intent-speccing/evals/evals.json +0 -126
  301. package/skills/intent-speccing/references/design-principles.md +0 -44
  302. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  303. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  304. package/skills/project-creating/SKILL.md +0 -162
  305. package/skills/project-creating/references/hubs-projects.md +0 -55
  306. package/skills/project-creating/references/project-scaffolding.md +0 -97
  307. package/skills/releasing/SKILL.md +0 -337
  308. package/skills/releasing/references/deprecations.md +0 -60
  309. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  310. package/skills/releasing/references/release-lines.md +0 -105
  311. package/skills/roadmap/SKILL.md +0 -64
  312. package/skills/roadmap/references/file-format.md +0 -124
  313. package/skills/roadmap/references/operations.md +0 -112
  314. package/skills/rollback/SKILL.md +0 -91
  315. package/skills/tutorial/SKILL.md +0 -65
  316. package/skills/tutorial/evals/evals.json +0 -186
  317. package/skills/uninstall/SKILL.md +0 -75
  318. package/skills/update/SKILL.md +0 -126
  319. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  320. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  321. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  322. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # hook-stop (intent 340b, G7c, n4, D5): the Stop hook body. Reads the harness
6
+ # payload from stdin, gathers the runtime config StopGate needs, and asks it
7
+ # whether this session may stop. Prints Claude Code's Stop block schema on
8
+ # stdout only when the answer is block; a permit prints nothing at all.
9
+ # Registration is static (hooks/hooks.json, settings.json) - this launcher
10
+ # always installs - and the decision is runtime, off by default
11
+ # (runner.stop_hook defaults false), so the owner flips continuation on or
12
+ # off by editing config with no reinstall. Every error permits: always
13
+ # exits 0, never raises out to the harness.
14
+ #
15
+ # Usage: hook-stop <plastic_home> (stdin: the hook JSON)
16
+
17
+ require_relative "lib/store_layout"
18
+ require "json"
19
+ require_relative "lib/stop_gate"
20
+
21
+ plastic_home = ARGV[0]
22
+ exit 0 if plastic_home.nil? || plastic_home.empty?
23
+
24
+ def read_config_value(key, home, read_config)
25
+ IO.popen({ "PLASTIC_HOME" => home }, [read_config, key], err: File::NULL, &:read).to_s.strip
26
+ rescue StandardError
27
+ ""
28
+ end
29
+
30
+ begin
31
+ raw = $stdin.tty? ? "" : $stdin.read.to_s
32
+ payload = raw.strip.empty? ? {} : JSON.parse(raw)
33
+ payload = {} unless payload.is_a?(Hash)
34
+
35
+ home = File.expand_path(plastic_home)
36
+ global_store = Plastic::StoreLayout.global_store(home)
37
+ read_config = File.expand_path("read-config", __dir__)
38
+
39
+ config_stop_hook = read_config_value("runner.stop_hook", home, read_config)
40
+ roots_raw = read_config_value("project_roots", home, read_config)
41
+ project_roots =
42
+ begin
43
+ roots_raw.strip.empty? ? [] : Array(JSON.parse(roots_raw))
44
+ rescue JSON::ParserError
45
+ []
46
+ end
47
+ project_roots = project_roots.map { |root| File.expand_path(root.to_s) }
48
+
49
+ session = payload["session_id"].to_s
50
+
51
+ result = StopGate.decide(payload: payload, config_stop_hook: config_stop_hook, session: session,
52
+ global_store: global_store, project_roots: project_roots)
53
+
54
+ puts JSON.generate("decision" => "block", "reason" => result[:reason]) if result[:block]
55
+ rescue StandardError
56
+ nil
57
+ end
58
+ exit 0
@@ -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__
@@ -11,8 +11,15 @@
11
11
  # `now:` seam is the test seam; the CLI uses the default Time.now, which is fine
12
12
  # because determinism is covered at the library level (test/insights_test.rb).
13
13
  #
14
+ # --rule (intent 341, G8, C37): tags the entry as a rule, not just an
15
+ # observation, by prepending the literal "rule: " onto the text before it
16
+ # ever reaches Insights.append_insight; the library itself stays untouched,
17
+ # since a tag is a text-level convention, not a new field on the ledger.
18
+ # Doctor's unpromoted_rules check (scripts/doctor.rb) later lists any tagged
19
+ # entry whose exact text no docs/help/*.md chapter carries yet.
20
+ #
14
21
  # Usage:
15
- # insight-append <intent_dir> <text> --stage S --author A
22
+ # insight-append <intent_dir> <text> --stage S --author A [--rule]
16
23
  #
17
24
  # Exit codes: 0 (entry appended), 2 (usage).
18
25
 
@@ -21,6 +28,7 @@ require_relative "lib/insights"
21
28
  def parse_args(argv)
22
29
  stage = nil
23
30
  author = nil
31
+ rule = false
24
32
  positional = []
25
33
  i = 0
26
34
  while i < argv.length
@@ -31,21 +39,26 @@ def parse_args(argv)
31
39
  when "--author"
32
40
  author = argv[i + 1]
33
41
  i += 2
42
+ when "--rule"
43
+ rule = true
44
+ i += 1
34
45
  else
35
46
  positional << argv[i]
36
47
  i += 1
37
48
  end
38
49
  end
39
- [positional[0], positional[1], stage, author]
50
+ [positional[0], positional[1], stage, author, rule]
40
51
  end
41
52
 
42
- intent_dir, text, stage, author = parse_args(ARGV)
53
+ intent_dir, text, stage, author, rule = parse_args(ARGV)
43
54
 
44
55
  if [intent_dir, text, stage, author].any? { |v| v.nil? || v.to_s.empty? }
45
- warn "usage: insight-append <intent_dir> <text> --stage S --author A"
56
+ warn "usage: insight-append <intent_dir> <text> --stage S --author A [--rule]"
46
57
  exit 2
47
58
  end
48
59
 
60
+ text = "rule: #{text}" if rule
61
+
49
62
  entry = Insights.append_insight(File.expand_path(intent_dir), text,
50
63
  stage: stage, author: author)
51
64
  puts "appended: #{entry}"
@@ -75,12 +75,15 @@ class Install < InstallerCore
75
75
  # they are reported, never re-installed, so the summary line and the Codex
76
76
  # trust reminder only ever count agents that actually changed this run.
77
77
  def run(selected:, force: false, reinstall: false, ledger_action: nil, argv: ARGV, input: $stdin,
78
- already_registered: [])
78
+ already_registered: [], qmd_runner: QmdSync.default_runner, qmd_detector: QmdSync.method(:detect))
79
79
  fresh = !installed?
80
80
  mode = fresh ? :install : :update # :update here means "re-sync, skip bootstrap"
81
81
 
82
82
  distribute(mode)
83
83
  bootstrap if fresh
84
+ git_init_if_absent
85
+ register_with_qmd(runner: qmd_runner, detector: qmd_detector)
86
+ migrate_advisor_config_file(File.join(plastic_home, "config.yml"))
84
87
  apply_config_flags(argv)
85
88
 
86
89
  results = selected.map do |key|
@@ -135,13 +138,13 @@ class Install < InstallerCore
135
138
  end
136
139
 
137
140
  def git_probe
138
- !`command -v git`.strip.empty?
141
+ !`git --version`.strip.empty?
139
142
  rescue StandardError
140
143
  false
141
144
  end
142
145
 
143
146
  def mise_probe
144
- !`command -v mise`.strip.empty?
147
+ !`mise --version`.strip.empty?
145
148
  rescue StandardError
146
149
  false
147
150
  end
@@ -239,10 +242,9 @@ class Install < InstallerCore
239
242
  --no-advisor Skip installing both advisor agents and the agent-advisor
240
243
  skill (advisor.enabled: false)
241
244
  --advisor VALUE Which advisor agent is the default: an agent name, or the
242
- shorthand "real" (plastic-advisor) or "faux"
243
- (plastic-faux-advisor). Writes advisor.claude.default. Left
244
- unset, the agent-advisor skill falls back to
245
- plastic-faux-advisor at consult time.
245
+ shorthand "primary" or "secondary". Legacy "real" and
246
+ "faux" aliases remain accepted. Writes advisor.claude.default.
247
+ Left unset, the agent-advisor skill falls back to Primary Advisor.
246
248
  -h, --help Show this help
247
249
 
248
250
  Notes:
@@ -0,0 +1,279 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "graph_file"
5
+ require_relative "graph_edges"
6
+ require_relative "node_file"
7
+ require_relative "savepoint"
8
+ require_relative "work_graph_validator"
9
+
10
+ # ActionGraphShim (intent 342, G9): a read-time backward shim that presents
11
+ # any intent directory's actions/*.md as a node graph, in the record shape
12
+ # NodeFile.parse returns, so 336, 338 and 339 read a legacy intent through
13
+ # the same call they already use for an authored graph.md (spec.md D1-D18).
14
+ #
15
+ # Writes nothing, anywhere, ever (D1). An authored graph.md always wins over
16
+ # the synthetic chain (D3) - the reverse of 334's forward shim, which
17
+ # resolves content and prefers the older, richer source. This one resolves
18
+ # structure, where an authored graph is the only real graph.
19
+ module ActionGraphShim
20
+ module_function
21
+
22
+ # D10's heading test: unchanged, four tokens, verified correct on the live
23
+ # store. Governs only whether a "## Files ..." heading itself is negated
24
+ # (e.g. "## Files you must NOT change"), never the section body.
25
+ FILES_NEGATION_RE = /\bnot\b|\bnever\b|\bavoid\b|don't/i.freeze
26
+
27
+ # D19's body-truncation vocabulary: the four heading tokens plus the
28
+ # exclusion phrases the live corpus actually uses ("Out of bounds, owned
29
+ # by leads running in parallel right now:" matched none of the original
30
+ # four). Governs whether a body LINE truncates the files harvest -
31
+ # never the heading test above, which stays on FILES_NEGATION_RE.
32
+ FILES_EXCLUSION_RE = /\bnot\b|\bnever\b|\bavoid\b|don't|\bout of bounds\b|\bout of scope\b|\bhands off\b|\bleave alone\b|\bexcluded\b/i.freeze
33
+
34
+ BACKTICK_RE = /`([^`]+)`/.freeze
35
+ PROVEN_BY_LABEL_RE = /\AS\d+\z/.freeze
36
+
37
+ # :authored when graph.md exists, :actions when it does not but the intent
38
+ # holds at least one real action file (D7's exact realness test, reused
39
+ # rather than reimplemented so the shim can never disagree with
40
+ # Savepoint.has_real_action? about whether an intent has work in it), :none
41
+ # otherwise. A directory that does not exist returns :none and never
42
+ # raises.
43
+ def shape(intent_dir)
44
+ return :none unless intent_dir && File.directory?(intent_dir.to_s)
45
+ return :authored if File.exist?(File.join(intent_dir, "graph.md"))
46
+ return :actions if Savepoint.has_real_files_in?("actions", intent_dir)
47
+
48
+ :none
49
+ end
50
+
51
+ # The seven-key hash GraphFile.parse returns (ok:, goal:, decisions:,
52
+ # graph:, status:, verify:, errors:), for whichever shape the directory
53
+ # is in. On :authored this is literally GraphFile.parse, unmodified (D3).
54
+ # On :actions the same hash is built from the action files, with goal:,
55
+ # decisions: and status: nil (a 2024 action file was never asked for
56
+ # them) and verify: a fixed reason so a trivial-bar check the caller may
57
+ # run stays self-consistent. On :none it is GraphFile.parse's own
58
+ # not-found failure, produced by delegating to it rather than
59
+ # reformatting the message ourselves.
60
+ def view(intent_dir)
61
+ case shape(intent_dir)
62
+ when :authored
63
+ GraphFile.parse(File.join(intent_dir, "graph.md"))
64
+ when :actions
65
+ synth = synthetic_graph(intent_dir)
66
+ {
67
+ ok: synth[:errors].empty?,
68
+ goal: nil,
69
+ decisions: nil,
70
+ graph: synth,
71
+ status: nil,
72
+ verify: { reason: "backward shim: legacy actions/ carry no verify node" },
73
+ errors: synth[:errors],
74
+ }
75
+ else
76
+ # 342 post-execution review, should-fix 5: a nil intent_dir must never
77
+ # raise. shape(nil) is already nil-guarded, so `view` and `needs`
78
+ # honor the same contract rather than raising a File.join TypeError.
79
+ return GraphFile.failure(["graph file not found: #{intent_dir.inspect}"]) unless intent_dir
80
+
81
+ GraphFile.parse(File.join(intent_dir, "graph.md"))
82
+ end
83
+ end
84
+
85
+ # Node records for either shape (D8), always carrying NodeFile.parse's own
86
+ # key set (ok:, node:, kind:, files:, budget:, report:, body:, errors:) plus
87
+ # needs:, path: and proven_by:. [] on :none, never a raise on a real
88
+ # directory.
89
+ def nodes(intent_dir)
90
+ case shape(intent_dir)
91
+ when :authored
92
+ authored_nodes(intent_dir)
93
+ when :actions
94
+ synthetic_nodes(intent_dir)
95
+ else
96
+ []
97
+ end
98
+ end
99
+
100
+ # The needs targets for one node id, under either shape; [] for an id the
101
+ # graph does not declare, and [] for a directory with no graph at all.
102
+ def needs(intent_dir, node_id)
103
+ graph = view(intent_dir)[:graph]
104
+ return [] unless graph
105
+
106
+ (graph[:edges] || {})[node_id] || []
107
+ end
108
+
109
+ # --- authored shape -------------------------------------------------------
110
+
111
+ def authored_nodes(intent_dir)
112
+ parsed = GraphFile.parse(File.join(intent_dir, "graph.md"))
113
+ edges = parsed.dig(:graph, :edges) || {}
114
+
115
+ # 342 post-execution review, nit 8: sort_key numerically over the
116
+ # basename, the same key the synthetic chain uses, so an authored
117
+ # intent with ten or more nodes orders n10 after n9 rather than
118
+ # lexically between n1 and n2.
119
+ Dir.glob(File.join(intent_dir, "nodes", "*.md"))
120
+ .sort_by { |path| sort_key(File.basename(path)) }
121
+ .map do |path|
122
+ nf = NodeFile.parse(path)
123
+ nf.merge(
124
+ needs: edges[nf[:node]] || [],
125
+ path: path,
126
+ proven_by: proven_by_labels(nf[:body].to_s)
127
+ )
128
+ end
129
+ end
130
+
131
+ # --- actions shape ----------------------------------------------------------
132
+
133
+ # {nodes:, edges:, errors:} built directly over the minted chain, in the
134
+ # same shape GraphEdges.parse returns, rather than rendering a "## Graph"
135
+ # section and reparsing it.
136
+ def synthetic_graph(intent_dir)
137
+ ids = real_action_files(intent_dir).each_index.map { |i| "n#{i + 1}" }
138
+ edges = {}
139
+ ids.each_with_index { |id, i| edges[id] = i.zero? ? [] : [ids[i - 1]] }
140
+ { nodes: ids, edges: edges, errors: [] }
141
+ end
142
+
143
+ def synthetic_nodes(intent_dir)
144
+ files = real_action_files(intent_dir)
145
+ files.each_with_index.map do |path, i|
146
+ id = "n#{i + 1}"
147
+ needs_targets = i.zero? ? [] : ["n#{i}"]
148
+ begin
149
+ text = File.read(path)
150
+ {
151
+ ok: true,
152
+ node: id,
153
+ kind: "work",
154
+ files: files_section_paths(text),
155
+ budget: nil,
156
+ report: nil,
157
+ body: text,
158
+ errors: [],
159
+ needs: needs_targets,
160
+ path: path,
161
+ proven_by: proven_by_labels(text),
162
+ }
163
+ rescue StandardError => e
164
+ {
165
+ ok: false,
166
+ node: id,
167
+ kind: "work",
168
+ files: [],
169
+ budget: nil,
170
+ report: nil,
171
+ body: nil,
172
+ errors: ["could not read #{path}: #{e.message}"],
173
+ needs: needs_targets,
174
+ path: path,
175
+ proven_by: [],
176
+ }
177
+ end
178
+ end
179
+ end
180
+
181
+ # Real action files (File.file?, non-zero size, Savepoint.stage_file_present?
182
+ # - D7's own three-part predicate, applied per file since has_real_files_in?
183
+ # only answers true/false), ordered by the integer trailing the basename,
184
+ # falling back to the basename itself so the key is a total order over
185
+ # every basename shape the live store holds (D4).
186
+ def real_action_files(intent_dir)
187
+ Dir.glob(File.join(intent_dir.to_s, "actions", "*.md"))
188
+ .select { |f| File.file?(f) && File.size(f) > 0 && Savepoint.stage_file_present?(f) }
189
+ .sort_by { |f| sort_key(File.basename(f)) }
190
+ end
191
+
192
+ def sort_key(basename)
193
+ m = basename.match(/\d+/)
194
+ m ? [0, m[0].to_i, basename] : [1, 0, basename]
195
+ end
196
+
197
+ # The first heading whose leading token is "Files" (case-insensitively),
198
+ # unless the heading text carries a negation, in which case it is not a
199
+ # files section and the search continues (D10). Paths come out of that
200
+ # section's BODY through extract_files_paths, which is itself
201
+ # negation-aware and fence-aware (D17, D18). No qualifying heading gives
202
+ # [] (D9).
203
+ def files_section_paths(text)
204
+ NodeFile.split_by_headings(text).each do |heading, section|
205
+ next unless files_heading?(heading)
206
+
207
+ return extract_files_paths(section.to_s)
208
+ end
209
+ []
210
+ end
211
+
212
+ # Fence-aware (D18) and negation-aware over the section body, not only
213
+ # its heading (D17). D10 already stops a heading like "## Files you must
214
+ # NOT change" from being read as a files section at all; this handles
215
+ # the equally common case where a legitimate "## Files to touch" heading
216
+ # is followed, inside the same section, by an out-of-bounds paragraph -
217
+ # live on this intent's own dogfood fixture. A fenced block is dropped
218
+ # before anything else runs, reusing NodeFile.each_fence_line rather than
219
+ # a bare backtick scan, which is exactly what that helper exists to
220
+ # prevent.
221
+ #
222
+ # Lines are scanned in document order; harvesting stops at the first line
223
+ # an exclusion GOVERNS (D19), not merely mentions - a negation is a false
224
+ # positive as often as a real exclusion clause, since an action file
225
+ # routinely annotates an in-bounds path with a "do NOT rename it" aside.
226
+ # governs_exclusion? draws the line: the match governs when it begins
227
+ # before the line's first backtick (an exclusion clause introducing
228
+ # paths) or when the line carries no backtick at all (a bare warning
229
+ # sentence); it annotates, and the line is kept, when the match falls
230
+ # after the first backtick (a parenthetical about a path already named).
231
+ # Any span that still holds a newline is rejected as a backstop: a path
232
+ # is never more than one line.
233
+ def extract_files_paths(section)
234
+ kept = +""
235
+ NodeFile.each_fence_line(section) do |line, fenced|
236
+ next if fenced
237
+ break if governs_exclusion?(line)
238
+
239
+ kept << line
240
+ end
241
+ kept.scan(BACKTICK_RE).flatten.reject { |s| s.include?("\n") }.uniq
242
+ end
243
+
244
+ # True when a FILES_EXCLUSION_RE match on this line governs the paths on
245
+ # it rather than merely annotating one (D19).
246
+ def governs_exclusion?(line)
247
+ match = line.match(FILES_EXCLUSION_RE)
248
+ return false unless match
249
+
250
+ first_backtick = line.index("`")
251
+ first_backtick.nil? || match.begin(0) < first_backtick
252
+ end
253
+
254
+ def files_heading?(heading)
255
+ stripped = heading.to_s.sub(/\A#+\s*/, "")
256
+ first_token = stripped.split(/\s+/, 2).first.to_s.sub(/[^A-Za-z]+\z/, "")
257
+ return false unless first_token.casecmp?("Files")
258
+
259
+ !heading.to_s.match?(FILES_NEGATION_RE)
260
+ end
261
+
262
+ # The S\d+ tokens of a file's headings, in heading order, taking a
263
+ # heading only when it owns at least one table data row (D5) - the same
264
+ # table-owning rule WorkGraphValidator.has_valid_matrix? and ReportScreen
265
+ # already apply, reused unmodified rather than adapted, so a prose
266
+ # mention of an S-label never manufactures a Proven-by source (334's
267
+ # post-execution review defect, in the other direction).
268
+ def proven_by_labels(text)
269
+ labels = []
270
+ NodeFile.split_by_headings(text).each do |heading, section|
271
+ next unless NodeFile.table_rows(section).any?
272
+
273
+ WorkGraphValidator.heading_tokens(heading).each do |token|
274
+ labels << token if token.match?(PROVEN_BY_LABEL_RE)
275
+ end
276
+ end
277
+ labels
278
+ end
279
+ end
@@ -0,0 +1,105 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require_relative "lock"
6
+
7
+ # ActiveDelivery (intent 340b, G7c, n4, D10): which intent a session is
8
+ # delivering. A lock is a file inside an intent directory, and intents live
9
+ # under the global store or under any configured project's store, so
10
+ # answering "which intent" needs a walk, not an index the way node_ids.rb
11
+ # indexes ids: one Dir.glob per configured root (plus one for the global
12
+ # store) and one JSON parse per candidate lock file. No validator, no graph
13
+ # rebuild, nothing else touches disk. Shared by StopGate (the fourth arming
14
+ # condition needs to know the lock's run_mode) and Handoff (the Runner
15
+ # section needs to know which intent's runner-step.last to render).
16
+ #
17
+ # Pure and dependency-injected: every call takes explicit paths; nothing
18
+ # here reads ENV, shells out, or raises across its own boundary.
19
+ module ActiveDelivery
20
+ module_function
21
+
22
+ # resolve(global_store:, project_roots:, session:, ttl:, now:) -> intent_dir or nil.
23
+ # Nil for zero matches or more than one: a session holding two live locks
24
+ # at once is a state nothing downstream may guess through (row 4.25).
25
+ def resolve(global_store:, project_roots:, session:, ttl: Lock::TTL_SECONDS, now: Time.now)
26
+ held = candidate_intent_dirs(global_store: global_store, project_roots: project_roots)
27
+ .uniq
28
+ .select { |dir| held_live?(dir, session: session, ttl: ttl, now: now) }
29
+ held.size == 1 ? held.first : nil
30
+ end
31
+
32
+ # Every intent directory under the global store or a configured project
33
+ # root that carries a delivery.lock file, unparsed. One Dir.glob per root
34
+ # (row 4.27): the global store's intents sit directly under store/, a
35
+ # project root's intents sit two levels down, under <root>/<slug>/store/.
36
+ def candidate_intent_dirs(global_store:, project_roots:)
37
+ dirs = []
38
+ dirs.concat(Dir.glob(File.join(global_store.to_s, "*", "delivery.lock"))) unless blank?(global_store)
39
+ Array(project_roots).each do |root|
40
+ next if blank?(root)
41
+
42
+ dirs.concat(Dir.glob(File.join(root.to_s, "*", "store", "*", "delivery.lock")))
43
+ end
44
+ dirs.map { |lock_path| File.dirname(lock_path) }
45
+ end
46
+
47
+ # Live and held by this session: a fresh (non-expired) lock whose owner or
48
+ # delegate list names this session. A torn or unparseable lock is skipped,
49
+ # never raised (row 4.26): a module a hook depends on must fail as quietly
50
+ # as the hook itself does.
51
+ def held_live?(intent_dir, session:, ttl:, now:)
52
+ return false unless Lock.fresh?(intent_dir, ttl: ttl, now: now)
53
+
54
+ Lock.holds?(intent_dir, session: session)
55
+ rescue StandardError
56
+ false
57
+ end
58
+
59
+ def blank?(value)
60
+ value.nil? || value.to_s.strip.empty?
61
+ end
62
+
63
+ DEFAULT_PROJECT_ROOTS = ["~/.plastic/projects", "~/.plastic/stores"].freeze
64
+
65
+ # The configured project roots (344 n2, D6): the `project_roots` list in
66
+ # `<plastic_home>/config.yml`, each expanded, or the default
67
+ # `~/.plastic/projects` when the key is absent or the file cannot be read
68
+ # or parsed. Never raises: a broken config.yml must never crash a hook on
69
+ # every prompt.
70
+ def project_roots(plastic_home)
71
+ path = File.join(plastic_home.to_s, "config.yml")
72
+ data = File.exist?(path) ? YAML.safe_load(File.read(path)) : nil
73
+ roots = data.is_a?(Hash) ? data["project_roots"] : nil
74
+ roots = DEFAULT_PROJECT_ROOTS unless roots.is_a?(Array) && !roots.empty?
75
+ roots.map { |root| File.expand_path(root.to_s) }
76
+ rescue StandardError
77
+ DEFAULT_PROJECT_ROOTS.map { |root| File.expand_path(root) }
78
+ end
79
+
80
+ # resolve_by_short_session(...) -> intent_dir or nil (344 n2). The tmp dir
81
+ # name is the short session id, while lock owners and delegates carry full
82
+ # ids, so this matches a lock whose owner or a delegate session STARTS WITH
83
+ # the short id. Nil for zero or more than one match, mirroring resolve's
84
+ # own uniqueness rule: an ambiguous short id names nothing downstream may
85
+ # guess through.
86
+ def resolve_by_short_session(global_store:, project_roots:, short_session:,
87
+ ttl: Lock::TTL_SECONDS, now: Time.now)
88
+ held = candidate_intent_dirs(global_store: global_store, project_roots: project_roots)
89
+ .uniq
90
+ .select { |dir| held_live_by_short?(dir, short_session: short_session, ttl: ttl, now: now) }
91
+ held.size == 1 ? held.first : nil
92
+ end
93
+
94
+ def held_live_by_short?(intent_dir, short_session:, ttl:, now:)
95
+ return false unless Lock.fresh?(intent_dir, ttl: ttl, now: now)
96
+
97
+ data = Lock.read(intent_dir)
98
+ return false unless data.is_a?(Hash)
99
+
100
+ sessions = [data["owner_session"]] + Array(data["delegates"])
101
+ sessions.compact.map(&:to_s).any? { |session| session.start_with?(short_session.to_s) }
102
+ rescue StandardError
103
+ false
104
+ end
105
+ end