@zalom/plastic 1.14.1 → 2.0.0-alpha.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 (189) hide show
  1. package/PLASTIC.md +101 -294
  2. package/README.md +5 -6
  3. package/agents/plastic-advisor.md +18 -17
  4. package/agents/plastic-enforcer.md +57 -69
  5. package/agents/plastic-executor.md +34 -20
  6. package/agents/plastic-faux-advisor.md +18 -18
  7. package/config_asks.yml +38 -0
  8. package/hooks/capture +4 -0
  9. package/hooks/close +5 -0
  10. package/hooks/hooks.json +9 -49
  11. package/hooks/record +4 -0
  12. package/package.json +3 -2
  13. package/scripts/agent-report +8 -9
  14. package/scripts/append-ledger +195 -0
  15. package/scripts/codex-hook +78 -90
  16. package/scripts/dashboard.rb +4 -4
  17. package/scripts/doctor.rb +106 -43
  18. package/scripts/end-intent +77 -76
  19. package/scripts/exec-worktree +11 -20
  20. package/scripts/file-session-intent +61 -0
  21. package/scripts/hook-capture +242 -0
  22. package/scripts/hook-close +34 -0
  23. package/scripts/hook-record +149 -0
  24. package/scripts/hook-session-start +95 -12
  25. package/scripts/install.rb +8 -6
  26. package/scripts/lib/agent_models.rb +1 -7
  27. package/scripts/lib/arm.rb +283 -0
  28. package/scripts/lib/backfill_intent.rb +316 -0
  29. package/scripts/lib/bridge.rb +24 -1597
  30. package/scripts/lib/doctor_core.rb +17 -88
  31. package/scripts/lib/doctor_session_ledger.rb +106 -0
  32. package/scripts/lib/exec_worktree.rb +14 -60
  33. package/scripts/lib/harness_text.rb +5 -4
  34. package/scripts/lib/hook_registry.rb +44 -107
  35. package/scripts/lib/insights.rb +3 -4
  36. package/scripts/lib/installer_core.rb +19 -30
  37. package/scripts/lib/lock.rb +6 -26
  38. package/scripts/lib/outcome_guard.rb +7 -5
  39. package/scripts/lib/project_validator.rb +30 -1
  40. package/scripts/lib/roadmap_queue.rb +3 -3
  41. package/scripts/lib/roadmap_savepoint.rb +1 -1
  42. package/scripts/lib/rule_catalog.rb +6 -3
  43. package/scripts/lib/savepoint.rb +320 -0
  44. package/scripts/lib/scaffold_intent.rb +13 -179
  45. package/scripts/lib/session_backfill.rb +184 -0
  46. package/scripts/lib/session_close.rb +84 -0
  47. package/scripts/lib/session_git.rb +469 -0
  48. package/scripts/lib/session_ledger.rb +497 -0
  49. package/scripts/lib/store_discovery.rb +1 -1
  50. package/scripts/lib/store_provisioning.rb +1 -1
  51. package/scripts/maintenance-run +7 -7
  52. package/scripts/new-intent +63 -4
  53. package/scripts/plastic-lock +94 -41
  54. package/scripts/promote-session-item +150 -0
  55. package/scripts/rebuild-graph +1 -1
  56. package/scripts/roadmap-next +2 -2
  57. package/scripts/rollback.rb +42 -3
  58. package/scripts/scaffold-intent +37 -63
  59. package/scripts/session-commit +116 -0
  60. package/scripts/spawn-preamble +5 -5
  61. package/scripts/update.rb +7 -3
  62. package/skills/agent-advisor/SKILL.md +3 -3
  63. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  64. package/skills/auto/SKILL.md +218 -317
  65. package/skills/auto/evals/evals.json +10 -10
  66. package/skills/auto/references/agent-architecture.md +102 -90
  67. package/skills/auto/references/agent-report-contract.md +4 -21
  68. package/skills/auto/references/end-tail.md +47 -53
  69. package/skills/auto/references/human-report-contract.md +8 -8
  70. package/skills/conventions/SKILL.md +1 -3
  71. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  72. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  73. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  74. package/skills/dashboard/SKILL.md +1 -1
  75. package/skills/direct/SKILL.md +66 -0
  76. package/skills/direct/references/request-signals.md +59 -0
  77. package/skills/doctor/SKILL.md +58 -11
  78. package/skills/doctor/report.md +3 -1
  79. package/skills/install/SKILL.md +11 -9
  80. package/skills/intent-continuing/SKILL.md +131 -89
  81. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  82. package/skills/intent-continuing/references/context-management.md +2 -2
  83. package/skills/intent-creating/SKILL.md +2 -2
  84. package/skills/intent-creating/references/lifecycle.md +1 -1
  85. package/skills/intent-ending/SKILL.md +28 -37
  86. package/skills/intent-ending/evals/evals.json +4 -4
  87. package/skills/intent-executing/SKILL.md +12 -49
  88. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  89. package/skills/intent-speccing/SKILL.md +108 -51
  90. package/skills/intent-speccing/evals/evals.json +5 -5
  91. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  92. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  93. package/skills/project-creating/SKILL.md +1 -1
  94. package/skills/releasing/SKILL.md +7 -8
  95. package/skills/tutorial/SKILL.md +2 -2
  96. package/skills/tutorial/references/track-1-guided.md +16 -18
  97. package/skills/tutorial/references/track-2-auto.md +12 -13
  98. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  99. package/templates/agents.md +4 -7
  100. package/templates/project.yml +7 -0
  101. package/templates/roadmap.md +1 -1
  102. package/templates/savepoint.md +2 -2
  103. package/templates/session-intent.md +29 -0
  104. package/agents/plastic-brainstorming.md +0 -48
  105. package/agents/plastic-future-intent-researcher.md +0 -35
  106. package/agents/plastic-intent-curator.md +0 -47
  107. package/agents/plastic-intent-discovery.md +0 -43
  108. package/agents/plastic-planner.md +0 -56
  109. package/agents/plastic-spec-specialist.md +0 -53
  110. package/hooks/auto-arm +0 -5
  111. package/hooks/bash-gate +0 -3
  112. package/hooks/continue +0 -31
  113. package/hooks/edit-gates +0 -3
  114. package/hooks/future-intent-check +0 -25
  115. package/hooks/gate-check +0 -12
  116. package/hooks/power-tools +0 -8
  117. package/scripts/hook-auto-arm +0 -51
  118. package/scripts/hook-bash-gate +0 -78
  119. package/scripts/hook-code-gate +0 -38
  120. package/scripts/hook-continue +0 -48
  121. package/scripts/hook-create-gate +0 -51
  122. package/scripts/hook-edit-gates +0 -58
  123. package/scripts/hook-future-intent-check +0 -90
  124. package/scripts/hook-gate-check +0 -169
  125. package/scripts/hook-links-gate +0 -54
  126. package/scripts/hook-lock-gate +0 -47
  127. package/scripts/hook-power-tools +0 -38
  128. package/scripts/hook-savepoint-pre +0 -32
  129. package/scripts/lib/codex_edit_gates.rb +0 -138
  130. package/scripts/lib/edit_gates.rb +0 -398
  131. package/scripts/lib/links_gate.rb +0 -140
  132. package/scripts/lib/qmd_hook.rb +0 -41
  133. package/scripts/lib/spec_header.rb +0 -83
  134. package/scripts/lib/start_intent.rb +0 -296
  135. package/scripts/start-intent +0 -89
  136. package/skills/_active-intent-gate.md +0 -26
  137. package/skills/auto/references/tiers.md +0 -100
  138. package/skills/continuing/SKILL.md +0 -34
  139. package/skills/continuing/evals/evals.json +0 -91
  140. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  141. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  142. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  143. package/skills/intent-brainstorming/SKILL.md +0 -118
  144. package/skills/intent-brainstorming/evals/evals.json +0 -67
  145. package/skills/intent-continuing/evals/evals.json +0 -145
  146. package/skills/intent-discovering/SKILL.md +0 -56
  147. package/skills/intent-grilling/SKILL.md +0 -108
  148. package/skills/intent-linking/SKILL.md +0 -128
  149. package/skills/intent-linking/evals/evals.json +0 -22
  150. package/skills/intent-linking/references/zettelkasten.md +0 -45
  151. package/skills/intent-locking/SKILL.md +0 -64
  152. package/skills/intent-planning/SKILL.md +0 -145
  153. package/skills/intent-planning/evals/evals.json +0 -81
  154. package/skills/intent-planning/references/plan-format.md +0 -130
  155. package/skills/intent-researching/SKILL.md +0 -123
  156. package/skills/intent-researching/evals/evals.json +0 -22
  157. package/skills/intent-savepoint/SKILL.md +0 -83
  158. package/skills/intent-savepoint/references/context-management.md +0 -32
  159. package/skills/intent-starting/SKILL.md +0 -151
  160. package/skills/intent-starting/evals/evals.json +0 -117
  161. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  162. package/skills/project-continuing/SKILL.md +0 -119
  163. package/skills/project-continuing/evals/evals.json +0 -100
  164. package/skills/roadmap-continuing/SKILL.md +0 -89
  165. package/skills/roadmap-continuing/evals/evals.json +0 -82
  166. package/skills/skill-creating/SKILL.md +0 -75
  167. package/skills/skill-creating/evals/evals.json +0 -108
  168. package/skills/skill-creating/references/agents.md +0 -168
  169. package/skills/skill-creating/references/defaults-first.md +0 -23
  170. package/skills/skill-creating/references/evals.md +0 -41
  171. package/skills/skill-creating/references/hooks.md +0 -251
  172. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  173. package/skills/skill-creating/references/scripts.md +0 -166
  174. package/skills/skill-creating/references/skills.md +0 -169
  175. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  176. package/skills/skill-evaluating/SKILL.md +0 -141
  177. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  178. package/skills/skill-evaluating/evals/evals.json +0 -75
  179. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  180. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  181. package/skills/store-curating/SKILL.md +0 -64
  182. package/skills/store-curating/evals/evals.json +0 -38
  183. package/skills/store-indexing/SKILL.md +0 -93
  184. package/skills/store-indexing/evals/evals.json +0 -22
  185. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  186. package/skills/store-provisioning/SKILL.md +0 -55
  187. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  188. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  189. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -2,8 +2,9 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # exec-worktree (intent 213, group 2) - finish an intent's code worktree: an order
6
- # precondition, then Worktree.finish (delivered merges + removes, abandoned removes only).
5
+ # exec-worktree (intent 213, group 2) - finish an intent's code worktree: the dirty-worktree
6
+ # guard, then Worktree.finish (delivered merges + removes, abandoned removes only). The order
7
+ # precondition it once ran first was removed in 2.0 with the gates (intent 302).
7
8
  #
8
9
  # Usage:
9
10
  # exec-worktree --store <path> --id <intent_id> --home <plastic_home> \
@@ -18,26 +19,17 @@
18
19
  # Steps performed:
19
20
  # 0. Resolve inputs: the intent dir, normalize --home, resolve the calling session
20
21
  # (--session, else CLAUDE_CODE_SESSION_ID, else the existing delivery.lock's own
21
- # recorded owner_session when non-blank, else nil; unlike start-intent, the
22
- # lock-owner fallback IS used here, matching end-intent's teardown resolution order).
23
- # Load the bridge (Bridge.read); a nil bridge means there is nothing to finish (exit
24
- # 0, loud: the worktree this intent provisioned, if any, was NOT removed). No
25
- # worktree.code on the bridge means nothing was provisioned (exit 0).
26
- # 1. The order precondition (delivered only): calls the EXISTING
27
- # Bridge.code_gate_decision predicate exactly as it exists. On an auto bridge that
28
- # has not reached How, this BLOCKS (exit 2). Bridge.code_gate_decision returns nil
29
- # UNCONDITIONALLY on a guided bridge (build.auto != true), so on a guided bridge
30
- # this precondition is ADVISORY ONLY: it always passes and the run proceeds. This
31
- # script is NOT the enforcement point either way; the hook layer
32
- # (scripts/lib/bridge.rb code_gate_decision, wired through scripts/lib/edit_gates.rb)
33
- # remains the actual gate. See scripts/lib/exec_worktree.rb's module doc for why the
34
- # auto-only condition is never relaxed and never duplicated here.
35
- # 2. The dirty-worktree guard (delivered only): git status --porcelain on the code
22
+ # recorded owner_session when non-blank, else nil; the lock-owner fallback IS
23
+ # used here, matching end-intent's teardown resolution order).
24
+ # Derive the worktree block from projects.yml and the intent id (Arm.worktree_block;
25
+ # the /tmp bridge this once read was removed in 2.0, intent 307). No code worktree on
26
+ # disk means nothing was provisioned (exit 0).
27
+ # 1. The dirty-worktree guard (delivered only): git status --porcelain on the code
36
28
  # worktree. Not clean, or the check itself fails, fails CLOSED (exit 3), mirroring
37
29
  # end-intent's dirty-worktree handling. abandoned skips this guard entirely: the
38
30
  # branch survives and can be reclaimed, so a dirty worktree is not a reason to
39
31
  # refuse.
40
- # 3. Finish: Worktree.finish(bridge_data, home:, runner:, merge: disposition ==
32
+ # 2. Finish: Worktree.finish(bridge_data, home:, runner:, merge: disposition ==
41
33
  # "delivered"). Worktree.finish is fail-open and never raises, so failure is
42
34
  # detected by checking, for a delivered disposition only, whether the worktree
43
35
  # directory still exists on disk afterward (release removes it on success).
@@ -49,8 +41,7 @@
49
41
  # 0 finished (merged or removed per disposition), or nothing to finish (no bridge
50
42
  # resolved, or no code worktree recorded on the bridge)
51
43
  # 1 usage or path-resolution failure
52
- # 2 the order precondition blocked a delivered disposition (auto bridge, How not
53
- # reached)
44
+ # 2 retired in 2.0 (intent 302): was the order precondition; never emitted, never reused
54
45
  # 3 the worktree is not clean, or the merge/removal did not complete (delivered only)
55
46
  # 4 lock or session resolution failure
56
47
 
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # file-session-intent (intent 301): backfill and close one session day ledger,
6
+ # carrying its open items into a target day.
7
+ #
8
+ # Usage:
9
+ # file-session-intent --day <YYYYMMDD> [--carry-to <YYYYMMDD>] [--store <dir>]
10
+ # [--templates <dir>] [--author <name>]
11
+ #
12
+ # --store defaults to $PLASTIC_HOME/store ($PLASTIC_HOME defaults to ~/.plastic).
13
+ # Prints one line: `filed <day>` or `skipped <day>: closed`. Exit 0 on success
14
+ # and on any filing error (reported on stderr; the next boot tries again), 2 on
15
+ # a usage error.
16
+
17
+ require_relative "lib/session_ledger"
18
+ require_relative "lib/session_backfill"
19
+
20
+ def usage_abort(message)
21
+ warn "file-session-intent: #{message}"
22
+ exit 2
23
+ end
24
+
25
+ def next_value!(argv, i, flag)
26
+ value = argv[i + 1]
27
+ usage_abort("#{flag} needs a value") if value.nil? || value.start_with?("--")
28
+ value
29
+ end
30
+
31
+ opts = { day: nil, carry_to: nil, store: nil, templates: nil, author: "session" }
32
+ i = 0
33
+ while i < ARGV.length
34
+ case ARGV[i]
35
+ when "--day" then opts[:day] = next_value!(ARGV, i, "--day"); i += 1
36
+ when "--carry-to" then opts[:carry_to] = next_value!(ARGV, i, "--carry-to"); i += 1
37
+ when "--store" then opts[:store] = next_value!(ARGV, i, "--store"); i += 1
38
+ when "--templates" then opts[:templates] = next_value!(ARGV, i, "--templates"); i += 1
39
+ when "--author" then opts[:author] = next_value!(ARGV, i, "--author"); i += 1
40
+ else usage_abort("unknown argument #{ARGV[i].inspect}")
41
+ end
42
+ i += 1
43
+ end
44
+
45
+ usage_abort("--day is required") if opts[:day].nil?
46
+ usage_abort("--day must be a real calendar date, YYYYMMDD") unless SessionLedger.valid_day_id?(opts[:day])
47
+ if opts[:carry_to] && !SessionLedger.valid_day_id?(opts[:carry_to])
48
+ usage_abort("--carry-to must be a real calendar date, YYYYMMDD")
49
+ end
50
+
51
+ store = opts[:store] || File.join(File.expand_path(ENV.fetch("PLASTIC_HOME", "~/.plastic")), "store")
52
+ templates = opts[:templates] || File.expand_path("../templates", __dir__)
53
+
54
+ begin
55
+ result = SessionBackfill.file_day(store: store, day: opts[:day], templates: templates,
56
+ author: opts[:author], carry_to: opts[:carry_to])
57
+ puts(result == :closed ? "skipped #{opts[:day]}: closed" : "filed #{opts[:day]}")
58
+ rescue StandardError => e
59
+ warn "file-session-intent: #{opts[:day]} not filed: #{e.class}: #{e.message}"
60
+ end
61
+ exit 0
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Usage: hook-capture (reads the UserPromptSubmit stdin JSON payload)
6
+ # Intent 298. One UserPromptSubmit process replacing hook-continue,
7
+ # hook-future-intent-check, and hook-auto-arm: it appends a pending line to
8
+ # the session day ledger, detects "continue" and "auto" prompts, and hints at
9
+ # matching Future intents. Every job runs in its own rescue and the hook
10
+ # always exits 0 (spec D2).
11
+
12
+ require "json"
13
+ require "yaml"
14
+ require "open3"
15
+ require "fileutils"
16
+ require_relative "lib/session_ledger"
17
+ require_relative "lib/dashboard_banner"
18
+
19
+ raw = begin
20
+ $stdin.read
21
+ rescue StandardError
22
+ nil
23
+ end
24
+ exit 0 if raw.nil? || raw.strip.empty?
25
+
26
+ payload = begin
27
+ JSON.parse(raw)
28
+ rescue StandardError
29
+ nil
30
+ end
31
+ exit 0 unless payload.is_a?(Hash)
32
+
33
+ session_id = payload["session_id"].to_s
34
+ prompt = payload["user_prompt"].to_s
35
+ cwd = payload["cwd"].to_s
36
+ cwd = Dir.pwd if cwd.empty?
37
+
38
+ plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
39
+ store = File.join(plastic_home, "store")
40
+ templates = File.expand_path("../templates", __dir__)
41
+ sid = SessionLedger.short_session_id(nil, session_id)
42
+ today = SessionLedger.day_id
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
+ def truncate(text, max)
126
+ return text if text.length <= max
127
+
128
+ "#{text[0, max - 3]}..."
129
+ end
130
+
131
+ # --- (a) tmp root + heartbeat, unconditional -------------------------------
132
+ begin
133
+ SessionLedger.ensure_tmp_root(store)
134
+ FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
135
+ File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
136
+ rescue StandardError
137
+ nil
138
+ end
139
+
140
+ # --- (b) read the pointer -----------------------------------------------
141
+ skip_day_ledger = false
142
+ begin
143
+ pointer = SessionLedger.pointer_path(store, sid)
144
+ if File.exist?(pointer)
145
+ current = File.read(pointer).strip
146
+ skip_day_ledger = !current.empty? && current != today
147
+ end
148
+ rescue StandardError
149
+ nil
150
+ end
151
+
152
+ # --- (c) pending checklist line -------------------------------------------
153
+ unless skip_day_ledger
154
+ begin
155
+ project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
156
+ clean = truncate(SessionLedger.sanitize_summary(prompt), 120)
157
+ if clean.length >= 10
158
+ SessionLedger.open_day(store: store, day: today, templates: templates, author: sid)
159
+ line = SessionLedger.checklist_line(:pending, sid, project, clean)
160
+ SessionLedger.append_line(SessionLedger.checklist_path(store, today), line,
161
+ header: SessionLedger.checklist_header(today))
162
+ end
163
+ rescue StandardError
164
+ nil
165
+ end
166
+ end
167
+
168
+ context_parts = []
169
+ system_message = nil
170
+
171
+ # --- (d) "continue" cockpit -------------------------------------------------
172
+ begin
173
+ if prompt.match?(/\bcontinue\b/i)
174
+ dashboard = File.expand_path("dashboard.rb", __dir__)
175
+ if File.exist?(dashboard)
176
+ cockpit, _err, status = Open3.capture3({ "RUBYOPT" => nil }, "ruby", dashboard, "continue")
177
+ if status.success? && !cockpit.strip.empty?
178
+ context_parts << (cockpit.rstrip +
179
+ "\n\nFollow the plastic-intent-continuing skill workflow to resume: " \
180
+ "if active intents exist, read their state and resume; otherwise surface the " \
181
+ "most-valuable next work and any stale or triage intents from the board above.")
182
+
183
+ begin
184
+ data_json, _data_err, data_status = Open3.capture3({ "RUBYOPT" => nil }, "ruby", dashboard,
185
+ "continue", "--data")
186
+ if data_status.success?
187
+ banner = DashboardBanner.render(JSON.parse(data_json))
188
+ system_message = banner if banner
189
+ end
190
+ rescue StandardError
191
+ nil
192
+ end
193
+ end
194
+ end
195
+ end
196
+ rescue StandardError
197
+ nil
198
+ end
199
+
200
+ # --- (e) auto-trigger steering (hook-auto-arm job 1 only) ------------------
201
+ begin
202
+ message_lower = prompt.downcase
203
+ auto_triggers = ["take it from here", "deliver this", "/plastic-auto", "auto mode"].freeze
204
+ triggered = auto_triggers.any? { |p| message_lower.include?(p) } || message_lower.match?(/\bauto\b/)
205
+ if triggered
206
+ context_parts << "Auto-mode request detected. Invoke the plastic-auto skill to deliver the " \
207
+ "active intent autonomously - it takes the intent under a lock and a worktree " \
208
+ "and points this session at it."
209
+ end
210
+ rescue StandardError
211
+ nil
212
+ end
213
+
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
+ exit 0 if context_parts.empty?
234
+
235
+ payload_out = {
236
+ "hookSpecificOutput" => {
237
+ "hookEventName" => "UserPromptSubmit",
238
+ "additionalContext" => context_parts.join("\n\n")
239
+ }
240
+ }
241
+ payload_out["systemMessage"] = system_message if system_message
242
+ puts JSON.generate(payload_out)
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # hook-close (intent 301): the SessionEnd hook. Reads the harness payload from
6
+ # stdin (session_id, cwd, reason), takes the Plastic home from argv, and runs
7
+ # SessionClose with the detached filer spawner. Always exits 0, prints nothing.
8
+ #
9
+ # Usage: hook-close <plastic_home> (stdin: the hook JSON)
10
+
11
+ require "json"
12
+ require_relative "lib/session_ledger"
13
+ require_relative "lib/session_close"
14
+
15
+ plastic_home = ARGV[0]
16
+ exit 0 if plastic_home.nil? || plastic_home.empty?
17
+
18
+ payload =
19
+ begin
20
+ raw = $stdin.tty? ? "" : $stdin.read
21
+ raw.to_s.strip.empty? ? {} : JSON.parse(raw)
22
+ rescue JSON::ParserError
23
+ {}
24
+ end
25
+
26
+ store = File.join(File.expand_path(plastic_home), "store")
27
+ filer = File.expand_path("file-session-intent", __dir__)
28
+ begin
29
+ SessionClose.run(payload: payload, store: store, today: SessionLedger.day_id,
30
+ spawner: SessionClose.default_spawner(filer))
31
+ rescue StandardError
32
+ nil
33
+ end
34
+ exit 0
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Usage: hook-record (reads the PostToolUse stdin JSON payload)
6
+ # Intent 298. Replaces hook-gate-check: never blocks, keeps the decoupled
7
+ # intent-dir savepoint append byte for byte, refreshes the delivery-lock lease
8
+ # for the owning session (intent 302, the lock gate's old allow-path job),
9
+ # promotes the session's newest pending day-ledger line when a project file
10
+ # lands, and calls the session-commit seam when that script exists. Always
11
+ # exits 0 (spec D3).
12
+
13
+ require "json"
14
+ require "timeout"
15
+ require "fileutils"
16
+ require_relative "lib/savepoint"
17
+ require_relative "lib/lock"
18
+ require_relative "lib/session_ledger"
19
+
20
+ raw = begin
21
+ $stdin.read
22
+ rescue StandardError
23
+ nil
24
+ end
25
+ exit 0 if raw.nil? || raw.strip.empty?
26
+
27
+ payload = begin
28
+ JSON.parse(raw)
29
+ rescue StandardError
30
+ nil
31
+ end
32
+ exit 0 unless payload.is_a?(Hash)
33
+
34
+ file_path = payload.dig("tool_input", "file_path") || payload.dig("tool_params", "file_path")
35
+ exit 0 if file_path.to_s.empty?
36
+
37
+ session_id = payload["session_id"].to_s
38
+ cwd = payload["cwd"].to_s
39
+ cwd = Dir.pwd if cwd.empty?
40
+
41
+ file_path_abs = File.expand_path(file_path)
42
+
43
+ # --- (a) decoupled savepoint ledger, byte for byte -------------------------
44
+ intent_dir_abs = nil
45
+ begin
46
+ intent_dir_abs = Savepoint.intent_dir_for(file_path_abs)
47
+ if intent_dir_abs
48
+ begin
49
+ Savepoint.append_savepoint(intent_dir_abs, file_path_abs)
50
+ if File.basename(file_path_abs) == "checklist.md" && Savepoint.stage_file_present?(file_path_abs)
51
+ Savepoint.append_exec_started(intent_dir_abs)
52
+ end
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ # The lease heartbeat (intent 302, spec D10): the lock gate's allow path used
57
+ # to refresh delivery.lock on every edit; record does it now. Lock.heartbeat
58
+ # refuses a session that does not hold the lock, so a stranger's write never
59
+ # refreshes the owner's lease.
60
+ begin
61
+ Lock.heartbeat(intent_dir_abs, session: session_id) unless session_id.empty?
62
+ rescue StandardError
63
+ nil
64
+ end
65
+ end
66
+ rescue StandardError
67
+ nil
68
+ end
69
+
70
+ plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
71
+
72
+ # --- (b) stop the day-ledger path when inside ~/.plastic or an intent dir --
73
+ inside_plastic = file_path_abs == plastic_home || file_path_abs.start_with?("#{plastic_home}/")
74
+ skip_day_ledger = inside_plastic || !intent_dir_abs.nil?
75
+
76
+ # --- (c) promote the newest pending line, Item savepoint, seam -------------
77
+ unless skip_day_ledger
78
+ begin
79
+ store = File.join(plastic_home, "store")
80
+ sid = SessionLedger.short_session_id(nil, session_id)
81
+ today = SessionLedger.day_id
82
+
83
+ proceed = true
84
+ pointer = SessionLedger.pointer_path(store, sid)
85
+ if File.exist?(pointer)
86
+ current = File.read(pointer).strip
87
+ proceed = current.empty? || current == today
88
+ end
89
+
90
+ if proceed
91
+ checklist = SessionLedger.checklist_path(store, today)
92
+ target_summary = begin
93
+ SessionLedger.set_state(checklist, from: :pending, to: :open, session: sid)
94
+ rescue SessionLedger::LockUnavailableError
95
+ nil
96
+ end
97
+
98
+ if target_summary
99
+ project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
100
+ line = SessionLedger.savepoint_line("Item", sid, project, target_summary, now: Time.now)
101
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, today), line, header: nil)
102
+
103
+ seam = File.expand_path("session-commit", __dir__)
104
+ if File.executable?(seam)
105
+ begin
106
+ # A bare Timeout.timeout around Open3.capture3 does not reliably bound a
107
+ # genuinely hanging child: capture3's internal wait still blocks on
108
+ # Process.waitpid for the child regardless of the raised Timeout::Error.
109
+ # Spawn directly and kill on timeout instead, so the 10s bound is real.
110
+ pid = Process.spawn({ "RUBYOPT" => nil }, seam, "--cwd", cwd, "--summary", target_summary,
111
+ "--session", sid, "--day", today,
112
+ [:out, :err] => File::NULL)
113
+ begin
114
+ Timeout.timeout(10) { Process.wait(pid) }
115
+ rescue Timeout::Error
116
+ begin
117
+ Process.kill("KILL", pid)
118
+ rescue StandardError
119
+ nil
120
+ end
121
+ begin
122
+ Process.wait(pid)
123
+ rescue StandardError
124
+ nil
125
+ end
126
+ end
127
+ rescue StandardError
128
+ nil
129
+ end
130
+ end
131
+ end
132
+ end
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ end
137
+
138
+ # --- (d) heartbeat, always -------------------------------------------------
139
+ begin
140
+ store ||= File.join(plastic_home, "store")
141
+ sid ||= SessionLedger.short_session_id(nil, session_id)
142
+ SessionLedger.ensure_tmp_root(store)
143
+ FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
144
+ File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
145
+ rescue StandardError
146
+ nil
147
+ end
148
+
149
+ exit 0
@@ -6,10 +6,13 @@
6
6
  require "json"
7
7
  require "date"
8
8
  require "yaml"
9
+ require "fileutils"
9
10
  require_relative "lib/bridge"
11
+ require_relative "lib/savepoint"
10
12
  require_relative "lib/boot_banner"
11
13
  require_relative "lib/qmd_sync"
12
14
  require_relative "lib/doctor_core"
15
+ require_relative "lib/session_ledger"
13
16
 
14
17
  index_path, plastic_home, mode, plugin_root = ARGV
15
18
  exit 0 unless index_path && plastic_home && mode
@@ -48,16 +51,20 @@ lines.each do |line|
48
51
  end
49
52
  end
50
53
 
51
- # --- Derive bridge for active intent ---
54
+ # --- Stage of the single active intent (the /tmp bridge this once derived was removed in 2.0, intent 307) ---
52
55
 
53
- bridge_data = nil
56
+ stage_line = nil
54
57
  if active.length == 1 && active.first =~ /store\/([\w-]+)\//
55
58
  dir_name = $1
56
59
  intent_dir = "#{store_dir}/#{dir_name}"
57
- session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
58
- intent_id = dir_name.split("--").first
59
- intent_name = active.first[/\[([^\]]+)\]/, 1] || "unknown"
60
- bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store_dir, name: intent_name)
60
+ begin
61
+ stage = Savepoint.derive_stage(intent_dir)
62
+ missing = Savepoint.missing_for_stage(stage, intent_dir)
63
+ missing_str = missing.empty? ? "none" : missing.join(", ")
64
+ stage_line = "Stage: #{stage} | Next: #{missing_str}"
65
+ rescue StandardError
66
+ stage_line = nil
67
+ end
61
68
  end
62
69
 
63
70
  # --- Detect stale future intents ---
@@ -251,12 +258,7 @@ if plastic_md
251
258
  if all_active.any?
252
259
  parts << "Active intents:\n"
253
260
  all_active.each { |a| parts << a }
254
- if bridge_data
255
- stage = bridge_data["build"]["stage"]
256
- missing = bridge_data["build"]["missing"]
257
- missing_str = missing.empty? ? "none" : missing.join(", ")
258
- parts << "Stage: #{stage} | Next: #{missing_str}"
259
- end
261
+ parts << stage_line if stage_line
260
262
  parts << ""
261
263
  else
262
264
  parts << "No active intents. #{future.length} future intents available.\n"
@@ -302,6 +304,87 @@ if update_notice
302
304
  parts.unshift("! #{update_notice}\n")
303
305
  end
304
306
 
307
+ # --- First-boot sweep (intent 301, spec D9): file every unclosed prior day
308
+ # ledger into today, oldest first, at most 3 per boot within a 5-second
309
+ # budget. Each day runs the sibling file-session-intent in its own rescue;
310
+ # the boot never blocks on it.
311
+ begin
312
+ require "open3"
313
+ require "timeout"
314
+ require_relative "lib/session_backfill"
315
+
316
+ sweep_today = SessionLedger.day_id
317
+ sweep_root = SessionLedger.sessions_root(store_dir)
318
+ sweep_filer = File.expand_path("file-session-intent", __dir__)
319
+ sweep_templates = File.expand_path("../templates", __dir__)
320
+ candidates = []
321
+ if Dir.exist?(sweep_root) && File.exist?(sweep_filer)
322
+ candidates = Dir.children(sweep_root).select do |name|
323
+ File.directory?(File.join(sweep_root, name)) && SessionLedger.valid_day_id?(name) &&
324
+ name < sweep_today && !SessionBackfill.closed?(store_dir, name)
325
+ end.sort
326
+ end
327
+ filed = 0
328
+ budget_end = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
329
+ candidates.first(3).each do |day|
330
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > budget_end
331
+
332
+ begin
333
+ Timeout.timeout(5) do
334
+ _out, _err, sweep_status = Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, sweep_filer,
335
+ "--day", day, "--carry-to", sweep_today,
336
+ "--store", store_dir, "--templates", sweep_templates)
337
+ filed += 1 if sweep_status.success? && SessionBackfill.closed?(store_dir, day)
338
+ end
339
+ rescue StandardError
340
+ nil
341
+ end
342
+ end
343
+ if filed.positive?
344
+ line = "PLASTIC: filed #{filed} prior day ledger(s) into #{sweep_today}"
345
+ remaining = candidates.size - filed
346
+ line += ", #{remaining} more wait for the next boot" if remaining.positive?
347
+ parts << line
348
+ end
349
+ rescue StandardError
350
+ nil
351
+ end
352
+
353
+ # --- Session day ledger: open or join today, write the per-session pointer
354
+ # and heartbeat (intent 298, spec D4). Best-effort: any failure here degrades
355
+ # to no ledger context line, never blocks the session.
356
+ begin
357
+ templates = File.expand_path("../templates", __dir__)
358
+ day = SessionLedger.day_id
359
+ author = `"#{read_config}" author`.strip
360
+ author = "session" if author.empty?
361
+ SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
362
+
363
+ session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
364
+ sid = SessionLedger.short_session_id(nil, session)
365
+ SessionLedger.ensure_tmp_root(store_dir)
366
+ FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
367
+ File.write(SessionLedger.heartbeat_path(store_dir, sid), "#{Time.now.utc.iso8601}\n")
368
+ pointer = SessionLedger.pointer_path(store_dir, sid)
369
+ File.write(pointer, "#{day}\n") unless File.exist?(pointer)
370
+
371
+ checklist = SessionLedger.checklist_path(store_dir, day)
372
+ open_count = 0
373
+ pending_count = 0
374
+ if File.exist?(checklist)
375
+ File.readlines(checklist).each do |line|
376
+ parsed = SessionLedger.parse_checklist_line(line)
377
+ next unless parsed
378
+
379
+ open_count += 1 if parsed[:state] == :open
380
+ pending_count += 1 if parsed[:state] == :pending
381
+ end
382
+ end
383
+ parts << "PLASTIC: day ledger #{day} joined (#{open_count} open items, #{pending_count} pending)"
384
+ rescue StandardError
385
+ nil
386
+ end
387
+
305
388
  # Emit nothing when there is genuinely nothing to surface (no conventions,
306
389
  # no deprecations, no update notice).
307
390
  exit 0 if parts.join.strip.empty?