@zalom/plastic 1.14.1 → 2.0.0-alpha.2

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 (200) 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/bin/lib/context_budget.rb +453 -0
  8. package/bin/plastic-bench +78 -0
  9. package/config_asks.yml +38 -0
  10. package/hooks/capture +4 -0
  11. package/hooks/close +5 -0
  12. package/hooks/hooks.json +9 -49
  13. package/hooks/record +4 -0
  14. package/hooks/savepoint +5 -5
  15. package/package.json +3 -2
  16. package/scripts/agent-report +8 -9
  17. package/scripts/append-ledger +208 -0
  18. package/scripts/codex-hook +78 -90
  19. package/scripts/dashboard.rb +4 -4
  20. package/scripts/day-summary +53 -0
  21. package/scripts/doctor.rb +106 -43
  22. package/scripts/end-intent +77 -76
  23. package/scripts/exec-worktree +11 -20
  24. package/scripts/file-session-intent +61 -0
  25. package/scripts/hook-capture +242 -0
  26. package/scripts/hook-close +36 -0
  27. package/scripts/hook-record +149 -0
  28. package/scripts/hook-savepoint +45 -0
  29. package/scripts/hook-session-start +106 -12
  30. package/scripts/install.rb +8 -6
  31. package/scripts/lib/agent_models.rb +1 -7
  32. package/scripts/lib/arm.rb +283 -0
  33. package/scripts/lib/backfill_intent.rb +316 -0
  34. package/scripts/lib/bridge.rb +24 -1597
  35. package/scripts/lib/compact_instructions.rb +56 -0
  36. package/scripts/lib/day_summary.rb +206 -0
  37. package/scripts/lib/doctor_core.rb +65 -88
  38. package/scripts/lib/doctor_session_ledger.rb +106 -0
  39. package/scripts/lib/exec_worktree.rb +14 -60
  40. package/scripts/lib/handoff.rb +184 -0
  41. package/scripts/lib/harness_text.rb +5 -4
  42. package/scripts/lib/hook_registry.rb +44 -107
  43. package/scripts/lib/insights.rb +3 -4
  44. package/scripts/lib/installer_core.rb +104 -40
  45. package/scripts/lib/lock.rb +6 -26
  46. package/scripts/lib/outcome_guard.rb +7 -5
  47. package/scripts/lib/project_validator.rb +30 -1
  48. package/scripts/lib/roadmap_queue.rb +3 -3
  49. package/scripts/lib/roadmap_savepoint.rb +1 -1
  50. package/scripts/lib/rule_catalog.rb +6 -3
  51. package/scripts/lib/savepoint.rb +320 -0
  52. package/scripts/lib/scaffold_intent.rb +13 -179
  53. package/scripts/lib/session_backfill.rb +184 -0
  54. package/scripts/lib/session_close.rb +104 -0
  55. package/scripts/lib/session_git.rb +469 -0
  56. package/scripts/lib/session_ledger.rb +497 -0
  57. package/scripts/lib/store_discovery.rb +1 -1
  58. package/scripts/lib/store_provisioning.rb +1 -1
  59. package/scripts/maintenance-run +7 -7
  60. package/scripts/new-intent +63 -4
  61. package/scripts/plastic-lock +94 -41
  62. package/scripts/promote-session-item +150 -0
  63. package/scripts/read-config +3 -0
  64. package/scripts/rebuild-graph +1 -1
  65. package/scripts/roadmap-next +2 -2
  66. package/scripts/rollback.rb +48 -3
  67. package/scripts/scaffold-intent +37 -63
  68. package/scripts/session-commit +116 -0
  69. package/scripts/spawn-preamble +5 -5
  70. package/scripts/update.rb +7 -3
  71. package/scripts/write-handoff +60 -0
  72. package/skills/agent-advisor/SKILL.md +3 -3
  73. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  74. package/skills/auto/SKILL.md +218 -317
  75. package/skills/auto/evals/evals.json +10 -10
  76. package/skills/auto/references/agent-architecture.md +102 -90
  77. package/skills/auto/references/agent-report-contract.md +4 -21
  78. package/skills/auto/references/end-tail.md +47 -53
  79. package/skills/auto/references/human-report-contract.md +8 -8
  80. package/skills/conventions/SKILL.md +1 -3
  81. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  82. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  83. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  84. package/skills/dashboard/SKILL.md +1 -1
  85. package/skills/direct/SKILL.md +66 -0
  86. package/skills/direct/references/request-signals.md +59 -0
  87. package/skills/doctor/SKILL.md +58 -11
  88. package/skills/doctor/report.md +3 -1
  89. package/skills/install/SKILL.md +11 -9
  90. package/skills/intent-continuing/SKILL.md +132 -89
  91. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  92. package/skills/intent-continuing/references/context-management.md +2 -2
  93. package/skills/intent-creating/SKILL.md +2 -2
  94. package/skills/intent-creating/references/lifecycle.md +1 -1
  95. package/skills/intent-ending/SKILL.md +28 -37
  96. package/skills/intent-ending/evals/evals.json +4 -4
  97. package/skills/intent-executing/SKILL.md +12 -49
  98. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  99. package/skills/intent-speccing/SKILL.md +108 -51
  100. package/skills/intent-speccing/evals/evals.json +5 -5
  101. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  102. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  103. package/skills/project-creating/SKILL.md +1 -1
  104. package/skills/releasing/SKILL.md +7 -8
  105. package/skills/tutorial/SKILL.md +2 -2
  106. package/skills/tutorial/references/track-1-guided.md +16 -18
  107. package/skills/tutorial/references/track-2-auto.md +12 -13
  108. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  109. package/templates/agents.md +4 -7
  110. package/templates/config.yml +5 -0
  111. package/templates/project.yml +7 -0
  112. package/templates/roadmap.md +1 -1
  113. package/templates/savepoint.md +2 -2
  114. package/templates/session-intent.md +29 -0
  115. package/agents/plastic-brainstorming.md +0 -48
  116. package/agents/plastic-future-intent-researcher.md +0 -35
  117. package/agents/plastic-intent-curator.md +0 -47
  118. package/agents/plastic-intent-discovery.md +0 -43
  119. package/agents/plastic-planner.md +0 -56
  120. package/agents/plastic-spec-specialist.md +0 -53
  121. package/hooks/auto-arm +0 -5
  122. package/hooks/bash-gate +0 -3
  123. package/hooks/continue +0 -31
  124. package/hooks/edit-gates +0 -3
  125. package/hooks/future-intent-check +0 -25
  126. package/hooks/gate-check +0 -12
  127. package/hooks/power-tools +0 -8
  128. package/scripts/hook-auto-arm +0 -51
  129. package/scripts/hook-bash-gate +0 -78
  130. package/scripts/hook-code-gate +0 -38
  131. package/scripts/hook-continue +0 -48
  132. package/scripts/hook-create-gate +0 -51
  133. package/scripts/hook-edit-gates +0 -58
  134. package/scripts/hook-future-intent-check +0 -90
  135. package/scripts/hook-gate-check +0 -169
  136. package/scripts/hook-links-gate +0 -54
  137. package/scripts/hook-lock-gate +0 -47
  138. package/scripts/hook-power-tools +0 -38
  139. package/scripts/hook-savepoint-pre +0 -32
  140. package/scripts/lib/codex_edit_gates.rb +0 -138
  141. package/scripts/lib/edit_gates.rb +0 -398
  142. package/scripts/lib/links_gate.rb +0 -140
  143. package/scripts/lib/qmd_hook.rb +0 -41
  144. package/scripts/lib/spec_header.rb +0 -83
  145. package/scripts/lib/start_intent.rb +0 -296
  146. package/scripts/start-intent +0 -89
  147. package/skills/_active-intent-gate.md +0 -26
  148. package/skills/auto/references/tiers.md +0 -100
  149. package/skills/continuing/SKILL.md +0 -34
  150. package/skills/continuing/evals/evals.json +0 -91
  151. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  152. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  153. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  154. package/skills/intent-brainstorming/SKILL.md +0 -118
  155. package/skills/intent-brainstorming/evals/evals.json +0 -67
  156. package/skills/intent-continuing/evals/evals.json +0 -145
  157. package/skills/intent-discovering/SKILL.md +0 -56
  158. package/skills/intent-grilling/SKILL.md +0 -108
  159. package/skills/intent-linking/SKILL.md +0 -128
  160. package/skills/intent-linking/evals/evals.json +0 -22
  161. package/skills/intent-linking/references/zettelkasten.md +0 -45
  162. package/skills/intent-locking/SKILL.md +0 -64
  163. package/skills/intent-planning/SKILL.md +0 -145
  164. package/skills/intent-planning/evals/evals.json +0 -81
  165. package/skills/intent-planning/references/plan-format.md +0 -130
  166. package/skills/intent-researching/SKILL.md +0 -123
  167. package/skills/intent-researching/evals/evals.json +0 -22
  168. package/skills/intent-savepoint/SKILL.md +0 -83
  169. package/skills/intent-savepoint/references/context-management.md +0 -32
  170. package/skills/intent-starting/SKILL.md +0 -151
  171. package/skills/intent-starting/evals/evals.json +0 -117
  172. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  173. package/skills/project-continuing/SKILL.md +0 -119
  174. package/skills/project-continuing/evals/evals.json +0 -100
  175. package/skills/roadmap-continuing/SKILL.md +0 -89
  176. package/skills/roadmap-continuing/evals/evals.json +0 -82
  177. package/skills/skill-creating/SKILL.md +0 -75
  178. package/skills/skill-creating/evals/evals.json +0 -108
  179. package/skills/skill-creating/references/agents.md +0 -168
  180. package/skills/skill-creating/references/defaults-first.md +0 -23
  181. package/skills/skill-creating/references/evals.md +0 -41
  182. package/skills/skill-creating/references/hooks.md +0 -251
  183. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  184. package/skills/skill-creating/references/scripts.md +0 -166
  185. package/skills/skill-creating/references/skills.md +0 -169
  186. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  187. package/skills/skill-evaluating/SKILL.md +0 -141
  188. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  189. package/skills/skill-evaluating/evals/evals.json +0 -75
  190. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  191. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  192. package/skills/store-curating/SKILL.md +0 -64
  193. package/skills/store-curating/evals/evals.json +0 -38
  194. package/skills/store-indexing/SKILL.md +0 -93
  195. package/skills/store-indexing/evals/evals.json +0 -22
  196. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  197. package/skills/store-provisioning/SKILL.md +0 -55
  198. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  199. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  200. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
package/hooks/savepoint CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/bin/bash
2
- cat <<'HOOKJSON'
3
- {
4
- "systemMessage": "PLASTIC SAVEPOINT — Context is being compacted.\n\nYou MUST perform the savepoint procedure NOW:\n1. Read .plastic/INDEX.md to find active intent(s)\n2. For each active intent in .plastic/store/ID--slug/:\n a. Update checklist.md (check off completed items)\n b. Create/update savepoint.md (in-progress work, next steps, blockers, discoveries)\n c. Update intent.md Build/Observe sections with session work\n3. Update .plastic/INDEX.md active section\n4. Commit: git add .plastic/ && git commit -m 'chore: savepoint — [intent name]'\n5. Tell the user: 'Context is getting large. I have saved progress to the active intent. Please run /clear and say continue to resume.'\n\nDO NOT skip this. The user's work depends on it."
5
- }
6
- HOOKJSON
2
+ INPUT=""
3
+ [ -t 0 ] || INPUT=$(cat)
4
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
5
+ echo "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-savepoint" "$HOME/.plastic"
6
+ exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.14.1",
3
+ "version": "2.0.0-alpha.2",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,7 @@
38
38
  "agents/",
39
39
  "templates/",
40
40
  "PLASTIC.md",
41
- "deprecations.yml"
41
+ "deprecations.yml",
42
+ "config_asks.yml"
42
43
  ]
43
44
  }
@@ -21,8 +21,7 @@
21
21
  #
22
22
  # Exit codes: 0 (report emitted), 2 (usage).
23
23
 
24
- require_relative "lib/bridge"
25
-
24
+ require_relative "lib/savepoint"
26
25
  def parse_args(argv)
27
26
  role = nil
28
27
  positional = []
@@ -43,7 +42,7 @@ end
43
42
  # Read the intent file frontmatter via the same path spawn-preamble uses, so the
44
43
  # id/intent we report match what the rest of Plastic sees. Never raises.
45
44
  def frontmatter_for(intent_dir)
46
- ifile = Bridge.intent_file(intent_dir)
45
+ ifile = Savepoint.intent_file(intent_dir)
47
46
  return {} unless File.exist?(ifile)
48
47
 
49
48
  content = File.read(ifile)
@@ -69,19 +68,19 @@ STAGE_LABELS = {
69
68
  }.freeze
70
69
 
71
70
  def current_stage(intent_dir)
72
- ledger = File.join(intent_dir, Bridge::SAVEPOINT_FILE)
71
+ ledger = File.join(intent_dir, Savepoint::SAVEPOINT_FILE)
73
72
  if File.exist?(ledger)
74
73
  last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
75
74
  return last if last
76
75
  end
77
- STAGE_LABELS.fetch(Bridge.derive_stage(intent_dir), Bridge.derive_stage(intent_dir))
76
+ STAGE_LABELS.fetch(Savepoint.derive_stage(intent_dir), Savepoint.derive_stage(intent_dir))
78
77
  end
79
78
 
80
79
  # checklist.md checked/total: count GFM task-list items. Returns "n/a" when there
81
80
  # is no checklist (a checklist is only meaningful from How onward).
82
81
  def checklist_progress(intent_dir)
83
82
  path = File.join(intent_dir, "checklist.md")
84
- return "n/a" unless Bridge.stage_file_present?(path)
83
+ return "n/a" unless Savepoint.stage_file_present?(path)
85
84
 
86
85
  lines = File.readlines(path)
87
86
  total = lines.count { |l| l =~ /^\s*- \[[ xX]\]/ }
@@ -95,7 +94,7 @@ end
95
94
  # non-heading content line), else a placeholder. Pure read of the intent dir.
96
95
  def outcome_line(intent_dir)
97
96
  path = File.join(intent_dir, "outcome.md")
98
- return "(no outcome yet)" unless Bridge.stage_file_present?(path)
97
+ return "(no outcome yet)" unless Savepoint.stage_file_present?(path)
99
98
 
100
99
  lines = File.readlines(path).map(&:rstrip)
101
100
  start = lines.index { |l| l.strip.downcase == "## summary" }
@@ -112,7 +111,7 @@ end
112
111
  # (the newest nugget, since entries are append-only newest-at-bottom), else
113
112
  # `(none)`. Pure read of the intent dir, no clock.
114
113
  def insights_line(intent_dir)
115
- ifile = Bridge.intent_file(intent_dir)
114
+ ifile = Savepoint.intent_file(intent_dir)
116
115
  return "(none)" unless File.exist?(ifile)
117
116
 
118
117
  lines = File.read(ifile).split("\n", -1)
@@ -145,7 +144,7 @@ intent_name = "(unknown)" if intent_name.empty?
145
144
 
146
145
  stage = current_stage(intent_dir)
147
146
  role_label = (role && !role.empty?) ? role : stage
148
- artifacts = Bridge.has_files(intent_dir)
147
+ artifacts = Savepoint.has_files(intent_dir)
149
148
  artifacts_str = artifacts.empty? ? "(none)" : artifacts.join(", ")
150
149
 
151
150
  lines = []
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # append-ledger (intent 297) - the only writer of a session day ledger's
6
+ # shared files. Every verb either appends a line to checklist.md or
7
+ # savepoint.md, or flips one checklist line's state marker byte, always
8
+ # under an exclusive lock on the target file itself (spec D6, D7, D8). Every
9
+ # invocation scaffolds the day itself through SessionLedger.open_day (cheap
10
+ # and idempotent), so a capture that crosses midnight never fails and never
11
+ # needs a second process (spec D12).
12
+ #
13
+ # Usage:
14
+ # append-ledger pending <summary> [common flags]
15
+ # append-ledger item <summary> [common flags]
16
+ # append-ledger promote [--match <substring>] [--savepoint] [common flags]
17
+ # append-ledger tick [--match <substring>] [--savepoint] [common flags]
18
+ # append-ledger savepoint --event Item|Done|Note <summary> [common flags]
19
+ #
20
+ # Common flags:
21
+ # --store <dir> defaults to $PLASTIC_HOME/store ($PLASTIC_HOME defaults to ~/.plastic)
22
+ # --day <YYYYMMDD> defaults to SessionLedger.day_id (today, local wall clock)
23
+ # --session <id> defaults to $CLAUDE_CODE_SESSION_ID, then "local"
24
+ # --project <slug> defaults to the project_slug derived from --cwd
25
+ # --cwd <dir> defaults to Dir.pwd
26
+ # --templates <dir> defaults to the templates/ dir shipped next to this script
27
+ #
28
+ # Exit codes:
29
+ # 0 - done
30
+ # 2 - usage error, or promote/tick found nothing to address (no write happens)
31
+ # 3 - promote/tick could not take an exclusive lock (filesystem without flock support)
32
+
33
+ require_relative "lib/session_ledger"
34
+ require_relative "lib/handoff"
35
+
36
+ VERBS = %w[pending item promote tick savepoint].freeze
37
+
38
+ def usage_abort(message)
39
+ warn "append-ledger: #{message}"
40
+ exit 2
41
+ end
42
+
43
+ # The value token that follows a flag at argv[i]. Aborts with exit 2 rather
44
+ # than silently returning nil when the flag is the last token (a trailing
45
+ # `--store` with nothing after it must not fall back to a default as if the
46
+ # flag had never been given).
47
+ def next_value!(argv, i, flag)
48
+ usage_abort("#{flag} requires a value") if i + 1 >= argv.length
49
+ argv[i + 1]
50
+ end
51
+
52
+ def parse_args(argv)
53
+ opts = {
54
+ match: nil, savepoint: false, event: nil,
55
+ store: nil, day: nil, session: nil, project: nil, cwd: nil, templates: nil,
56
+ }
57
+ positional = []
58
+ i = 0
59
+ while i < argv.length
60
+ arg = argv[i]
61
+ case arg
62
+ when "--match" then opts[:match] = next_value!(argv, i, arg); i += 1
63
+ when "--event" then opts[:event] = next_value!(argv, i, arg); i += 1
64
+ when "--store" then opts[:store] = next_value!(argv, i, arg); i += 1
65
+ when "--day" then opts[:day] = next_value!(argv, i, arg); i += 1
66
+ when "--session" then opts[:session] = next_value!(argv, i, arg); i += 1
67
+ when "--project" then opts[:project] = next_value!(argv, i, arg); i += 1
68
+ when "--cwd" then opts[:cwd] = next_value!(argv, i, arg); i += 1
69
+ when "--templates" then opts[:templates] = next_value!(argv, i, arg); i += 1
70
+ when "--savepoint" then opts[:savepoint] = true
71
+ else
72
+ usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
73
+
74
+ positional << arg
75
+ end
76
+ i += 1
77
+ end
78
+
79
+ opts[:verb] = positional.shift
80
+ opts[:summary] = positional.shift
81
+ opts
82
+ end
83
+
84
+ def expand(path)
85
+ File.expand_path(path.to_s.sub(/\A~/, Dir.home))
86
+ end
87
+
88
+ # Sibling of this script's dir. Works in-repo (<repo>/scripts/append-ledger
89
+ # -> <repo>/templates) and installed (~/.plastic/scripts/append-ledger ->
90
+ # ~/.plastic/templates when present).
91
+ def default_templates_dir
92
+ File.expand_path("../templates", __dir__)
93
+ end
94
+
95
+ def sanitized_summary(raw)
96
+ usage_abort("a summary is required") if raw.nil?
97
+
98
+ clean = SessionLedger.sanitize_summary(raw)
99
+ usage_abort("summary is empty after sanitizing") if clean.empty?
100
+ clean
101
+ end
102
+
103
+ def append_checklist_line(state, store, day, session, project, summary)
104
+ clean = sanitized_summary(summary)
105
+ line = SessionLedger.checklist_line(state, session, project, clean)
106
+ path = SessionLedger.checklist_path(store, day)
107
+ SessionLedger.append_line(path, line, header: SessionLedger.checklist_header(day))
108
+ end
109
+
110
+ # set_state identifies the target line AND flips it inside one LOCK_EX hold,
111
+ # returning the flipped line's own summary (or nil when nothing matched).
112
+ # There is deliberately no separate identify-then-flip step here: a prior
113
+ # version located the target under its own released lock, then set_state
114
+ # re-located it under a fresh lock, and the two scans could disagree under
115
+ # concurrency, naming the wrong line in the --savepoint entry below. Using
116
+ # set_state's return value is what keeps identify-and-flip atomic.
117
+ def run_transition(from, to, event, store, day, session, project, opts)
118
+ path = SessionLedger.checklist_path(store, day)
119
+
120
+ target_summary =
121
+ begin
122
+ SessionLedger.set_state(path, from: from, to: to, session: session, match: opts[:match])
123
+ rescue SessionLedger::LockUnavailableError => e
124
+ warn "append-ledger: #{e.message}"
125
+ exit 3
126
+ end
127
+
128
+ if target_summary.nil?
129
+ warn "append-ledger: nothing to address"
130
+ exit 2
131
+ end
132
+
133
+ if opts[:savepoint]
134
+ line = SessionLedger.savepoint_line(event, session, project, target_summary, now: Time.now)
135
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, day), line, header: nil)
136
+ end
137
+
138
+ return unless to == :done
139
+
140
+ # The hand-off at every tick (intent 311, spec D4): derived and
141
+ # regenerable, so a failure here never fails the tick that was recorded.
142
+ begin
143
+ Handoff.write(store: store, day: day, session: session, trigger: "tick",
144
+ templates: opts[:templates] || default_templates_dir)
145
+ rescue StandardError
146
+ nil
147
+ end
148
+ end
149
+
150
+ def run_savepoint(store, day, session, project, opts)
151
+ unless SessionLedger::EVENTS.include?(opts[:event])
152
+ usage_abort("--event must be one of #{SessionLedger::EVENTS.join(", ")}, got #{opts[:event].inspect}")
153
+ end
154
+
155
+ clean = sanitized_summary(opts[:summary])
156
+ line = SessionLedger.savepoint_line(opts[:event], session, project, clean, now: Time.now)
157
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, day), line, header: nil)
158
+ end
159
+
160
+ def main(argv)
161
+ opts = parse_args(argv)
162
+ usage_abort("verb must be one of #{VERBS.join(", ")}, got #{opts[:verb].inspect}") unless VERBS.include?(opts[:verb])
163
+
164
+ plastic_home = expand(ENV.fetch("PLASTIC_HOME", "~/.plastic"))
165
+ store = opts[:store] ? expand(opts[:store]) : File.join(plastic_home, "store")
166
+ templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
167
+ usage_abort("templates dir not found: #{templates}") unless Dir.exist?(templates)
168
+ opts[:templates] = templates
169
+ cwd = opts[:cwd] ? expand(opts[:cwd]) : Dir.pwd
170
+
171
+ day = opts[:day] || SessionLedger.day_id
172
+ unless SessionLedger.valid_day_id?(day)
173
+ usage_abort("--day must be eight digits parsing as a real date, got #{day.inspect}")
174
+ end
175
+
176
+ if opts[:project] && !SessionLedger::SLUG_RE.match?(opts[:project])
177
+ usage_abort("--project must match [a-z0-9-]+, got #{opts[:project].inspect}")
178
+ end
179
+
180
+ session = SessionLedger.short_session_id(opts[:session], ENV["CLAUDE_CODE_SESSION_ID"])
181
+ project = opts[:project] || SessionLedger.project_slug(cwd, plastic_home: plastic_home)
182
+
183
+ # Always call open_day: it is idempotent (File::CREAT | File::EXCL decides
184
+ # create vs join) and cheap, so this never depends on Dir.exist?(day_dir)
185
+ # as a proxy for "does the day file exist". A guard keyed on the DIRECTORY
186
+ # missed the case of a day dir that exists without its <day>.md (a crashed
187
+ # scaffold, or a template-read failure): that guard would see the
188
+ # directory, skip open_day forever, and every append would land in a day
189
+ # dir with no intent file at all.
190
+ SessionLedger.open_day(store: store, day: day, templates: templates, author: session)
191
+
192
+ case opts[:verb]
193
+ when "pending"
194
+ append_checklist_line(:pending, store, day, session, project, opts[:summary])
195
+ when "item"
196
+ append_checklist_line(:open, store, day, session, project, opts[:summary])
197
+ when "promote"
198
+ run_transition(:pending, :open, "Item", store, day, session, project, opts)
199
+ when "tick"
200
+ run_transition(:open, :done, "Done", store, day, session, project, opts)
201
+ when "savepoint"
202
+ run_savepoint(store, day, session, project, opts)
203
+ end
204
+
205
+ exit 0
206
+ end
207
+
208
+ main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -2,44 +2,41 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
  #
5
- # Usage: codex-hook <gate>
6
- # file-mutation gates: edit-gates | gate-check
7
- # live-state hooks (intent 199): session-start | check-update | continue |
8
- # future-intent-check | auto-arm | power-tools | savepoint
9
- # shell-tool gate (intent 203): bash-gate
5
+ # Usage: codex-hook <hook>
6
+ # file-mutation hook: record (PostToolUse on apply_patch)
7
+ # live-state hooks (intent 199, capture/record collapse by 298):
8
+ # session-start | check-update | capture | savepoint
9
+ # session-end hook (intent 309): close, handed off detached
10
10
  #
11
- # The Codex input adapter (intent 102, extended by 199, 203, and collapsed by
12
- # 251). Reads a Codex hook stdin payload once. Three shapes exist and the gate
13
- # name alone selects which path runs (Codex's own hooks.json registration
14
- # guarantees they never cross): the apply_patch path reads session_id at top
15
- # level and the apply_patch command in tool_input.command [guide Part 4],
16
- # parses the diff envelope once via ApplyPatchEnvelope, and runs all five gates
17
- # (savepoint-pre, lock-gate, code-gate, links-gate, create-gate) IN-PROCESS
18
- # through scripts/lib/codex_edit_gates.rb, which drives the same
19
- # scripts/lib/edit_gates.rb functions Claude's merged dispatcher drives
20
- # (mirroring intent 244). This replaced five registered PreToolUse commands and
21
- # three nested run_core children with one process. The live-state hooks carry
22
- # no tool_input at all (they are not tool calls); the dispatcher execs the SAME
23
- # launcher file Claude already runs for that hook (hooks/<name>), which is
24
- # already harness-agnostic (resolves ~/.plastic off $HOME, reads only the
25
- # common stdin fields the guide confirms Codex shares with Claude for these
26
- # three events), so the body is reused, not rewritten (D3). The shell-tool gate
27
- # (bash-gate) reports tool_name: "Bash" with the command in tool_input.command,
28
- # but MUST NOT be routed through ApplyPatchEnvelope.parse below: a plain shell
29
- # command has no *** Begin Patch/*** End Patch envelope, so it would parse to
30
- # an empty op list and hit this file's own `exit 0 if ops.empty?` line,
31
- # silently allowing every Bash call and reopening the exact hole intent 203
32
- # closes. So it execs the SAME scripts/hook-bash-gate file Claude already runs,
33
- # unmodified, the identical "drive the body, relay its output" pattern used for
34
- # the live-state hooks. The one adaptation shared by both the live-state and
35
- # shell-tool paths is threading the payload's session_id into
36
- # CLAUDE_CODE_SESSION_ID, since Codex's own process env never carries it,
37
- # plus a bounded timeout: Codex invokes hooks synchronously, so a launcher
38
- # that never releases this dispatcher's pipes would hang the session.
39
- # hooks/check-update used to leak them by backgrounding an npm network call
40
- # with the inherited descriptors (fixed at the root in intent 289 by
41
- # redirecting that subshell to /dev/null); the bound stays for any other
42
- # launcher that misbehaves, and capture_launcher keeps the timeout path silent.
11
+ # The Codex input adapter (intent 102, extended by 199, collapsed by 251, cut to
12
+ # the record hook by 302). Reads a Codex hook stdin payload once. Two shapes
13
+ # exist and the hook name alone selects which path runs (Codex's own hooks.json
14
+ # registration guarantees they never cross): the apply_patch path reads
15
+ # session_id at top level and the apply_patch command in tool_input.command
16
+ # [guide Part 4] and parses the diff envelope once via ApplyPatchEnvelope; the
17
+ # live-state hooks carry no tool_input at all (they are not tool calls), so the
18
+ # dispatcher execs the SAME launcher file Claude already runs for that hook
19
+ # (hooks/<name>), which is already harness-agnostic (resolves ~/.plastic off
20
+ # $HOME, reads only the common stdin fields the guide confirms Codex shares with
21
+ # Claude for these events), so the body is reused, not rewritten (D3). The one
22
+ # adaptation on the live-state path is threading the payload's session_id into
23
+ # CLAUDE_CODE_SESSION_ID, since Codex's own process env never carries it, plus a
24
+ # bounded timeout: Codex invokes hooks synchronously, so a launcher that never
25
+ # releases this dispatcher's pipes would hang the session. hooks/check-update
26
+ # used to leak them by backgrounding an npm network call with the inherited
27
+ # descriptors (fixed at the root in intent 289 by redirecting that subshell to
28
+ # /dev/null); the bound stays for any other launcher that misbehaves, and
29
+ # capture_launcher keeps the timeout path silent.
30
+ #
31
+ # The PreToolUse edit-path gates and the Bash-matcher shell gate were removed in
32
+ # 2.0 (intent 302), and the power-tools reminder in intent 309. An older
33
+ # ~/.codex/hooks.json that still names one of them reaches the fail-open `else`
34
+ # below: exit 0, no output, no decision.
35
+ #
36
+ # SessionEnd (intent 309): Codex kills a SessionEnd hook after 3 seconds, so `close`
37
+ # is never relayed synchronously. The dispatcher hands the payload to hooks/close in a
38
+ # detached child (own process group, stdin from a pipe, stdout and stderr to /dev/null)
39
+ # and exits at once; the close hook's own detached filer does the slow part.
43
40
  require "json"
44
41
  require "open3"
45
42
  require "rbconfig"
@@ -52,11 +49,11 @@ require "timeout"
52
49
  # caller that merges the dispatcher's own stdout and stderr.
53
50
  $stdout.sync = true
54
51
 
55
- STATE_HOOKS = %w[session-start check-update continue future-intent-check auto-arm power-tools savepoint].freeze
52
+ STATE_HOOKS = %w[session-start check-update capture savepoint close].freeze
56
53
  STATE_TIMEOUT = 5
57
- SHELL_HOOKS = %w[bash-gate].freeze
54
+ DETACHED_HOOKS = %w[close].freeze
58
55
 
59
- # One capture path for both relayed-launcher branches (intent 289). The 5 s bound covers a
56
+ # One capture path for the relayed-launcher branch (intent 289). The 5 s bound covers a
60
57
  # launcher that leaks THIS process's stdout and stderr into a longer-lived background child
61
58
  # (hooks/check-update did exactly that before 289): it does not bound a launcher that simply
62
59
  # never exits in the foreground, since Open3.popen3's ensure joins the child's wait thread on
@@ -80,6 +77,28 @@ def capture_launcher(env, *argv, raw:, cwd:)
80
77
  end
81
78
  end
82
79
 
80
+ # Hand a launcher its payload and return without waiting (intent 309). The child gets its
81
+ # own process group so the harness's teardown of this dispatcher never reaches it, reads
82
+ # the payload from a pipe this process fills without blocking and closes, and writes
83
+ # nowhere. Every failure (a missing or unexecutable launcher, a closed pipe) is rescued
84
+ # silently, so nothing ever reaches Codex's stderr from this path.
85
+ def detach_launcher(env, launcher, raw:, cwd:)
86
+ reader, writer = IO.pipe
87
+ pid = Process.spawn(env, launcher, in: reader, out: File::NULL, err: File::NULL,
88
+ chdir: cwd, pgroup: true)
89
+ reader.close
90
+ begin
91
+ writer.write_nonblock(raw) # a hook payload fits the pipe buffer; a larger one is truncated
92
+ rescue IO::WaitWritable, SystemCallError
93
+ nil
94
+ ensure
95
+ writer.close
96
+ end
97
+ Process.detach(pid)
98
+ rescue StandardError
99
+ nil # fail open: a close that cannot start is a close the next-day sweep files
100
+ end
101
+
83
102
  gate = ARGV[0].to_s
84
103
  raw = ($stdin.read rescue nil)
85
104
  exit 0 if raw.nil? || raw.strip.empty?
@@ -93,73 +112,42 @@ if STATE_HOOKS.include?(gate)
93
112
  cwd = payload["cwd"].to_s
94
113
  cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
95
114
  env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
115
+ if DETACHED_HOOKS.include?(gate)
116
+ detach_launcher(env.merge("RUBYOPT" => nil), launcher, raw: raw, cwd: cwd) if File.file?(launcher)
117
+ exit 0
118
+ end
96
119
  out, err, status = capture_launcher(env.merge("RUBYOPT" => nil), launcher, raw: raw, cwd: cwd)
97
120
  print out unless out.to_s.empty?
98
121
  warn err unless err.to_s.empty?
99
122
  exit(status ? status.exitstatus : 0)
100
123
  end
101
124
 
102
- if SHELL_HOOKS.include?(gate)
103
- # bash-gate (intent 203): a shell command has no apply_patch diff envelope, so
104
- # this branch execs the SAME scripts/hook-bash-gate file Claude already runs,
105
- # unmodified, and relays its exit code and stderr. It must never fall through
106
- # to ApplyPatchEnvelope.parse below (see header comment).
107
- cwd = payload["cwd"].to_s
108
- cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
109
- env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
110
- script = File.join(__dir__, "hook-#{gate}")
111
- argv = [script]
112
- out, err, status = capture_launcher(env.merge("RUBYOPT" => nil), RbConfig.ruby, *argv, raw: raw, cwd: cwd)
113
- print out unless out.to_s.empty?
114
- warn err unless err.to_s.empty?
115
- exit(status ? status.exitstatus : 0)
116
- end
117
-
118
125
  require_relative "lib/apply_patch_envelope"
119
126
 
120
127
  command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
121
- exit 0 if command.nil? # fail-open: no tool_input at all (unrecognized gate name or non-tool-call payload)
128
+ exit 0 if command.nil? # fail-open: no tool_input at all (unrecognized hook name or non-tool-call payload)
122
129
  ops = ApplyPatchEnvelope.parse(command)
123
- exit 0 if ops.empty? # fail-open: nothing parseable to gate
130
+ exit 0 if ops.empty? # fail-open: nothing parseable to record
124
131
 
125
132
  CORES = __dir__ # ~/.plastic/scripts
126
- def run_core(name, *argv)
127
- out = IO.popen([{ "RUBYOPT" => nil }, RbConfig.ruby, File.join(CORES, name), *argv], "r", err: [:child, :out], &:read)
128
- [out, $?.exitstatus]
129
- end
130
133
 
131
134
  case gate
132
- when "edit-gates"
133
- # The ONE registered PreToolUse command on the apply_patch matcher (intent
134
- # 251). Five gates, in-process, in Claude's evaluation order, first deny wins,
135
- # each deny shape reproduced exactly: stderr plus exit 2 for code-gate,
136
- # links-gate and create-gate; stdout permissionDecision JSON plus exit 0 for
137
- # lock-gate. Fail-open at both levels, per gate inside EditGates.dispatch and
138
- # again at this file's own top level.
139
- begin
140
- require_relative "lib/codex_edit_gates"
141
- exit CodexEditGates.dispatch(ops: ops, payload: payload)
142
- rescue StandardError, ScriptError => e
143
- # Dispatcher-internal failure: never impersonate a deny. ScriptError (a
144
- # sibling of StandardError, not a subclass) covers LoadError: a future
145
- # manifest gap (a lib file required here but not distributed, exactly the
146
- # bug the full suite caught during this intent's own delivery) must fail
147
- # open with a gate decision, never exit 1 with no decision at all.
148
- $stderr.puts "plastic codex edit-gates error: #{e.message}"
149
- exit 0
150
- end
151
-
152
- when "gate-check"
153
- last_allow = nil
135
+ when "record"
136
+ # hook-record (intent 298) reads one stdin JSON payload; it takes no ARGV. One
137
+ # apply_patch call can bundle several file operations, so this synthesizes one
138
+ # Claude-shaped PostToolUse payload per op and pipes it to hook-record in-process,
139
+ # mirroring the "drive the body" pattern used for the live-state hooks. record
140
+ # never blocks and never prints anything (D3), so there is nothing to relay: every
141
+ # op's failure is independently rescued and the dispatcher always exits 0.
142
+ cwd_val = payload["cwd"].to_s
154
143
  ops.each do |o|
155
- out, code = run_core("hook-gate-check", o.path, session)
156
- if code == 2
157
- print out # {"decision":"block",...}
158
- exit 2
144
+ synth = JSON.generate("session_id" => session, "tool_input" => { "file_path" => o.path }, "cwd" => cwd_val)
145
+ begin
146
+ Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, File.join(CORES, "hook-record"), stdin_data: synth)
147
+ rescue StandardError
148
+ nil
159
149
  end
160
- last_allow = out unless out.to_s.strip.empty?
161
150
  end
162
- print last_allow if last_allow
163
151
  exit 0
164
152
 
165
153
  else
@@ -25,7 +25,7 @@ require "json"
25
25
  require "yaml"
26
26
  require "date"
27
27
  require_relative "doctor"
28
- require_relative "lib/bridge"
28
+ require_relative "lib/savepoint"
29
29
  require_relative "lib/lock"
30
30
 
31
31
  PLASTIC_HOME = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
@@ -36,7 +36,7 @@ def today
36
36
  end
37
37
 
38
38
  # A generic "about a month" threshold, not tuned to any one store's item count.
39
- # Deliberately independent from plastic-project-continuing's separate stale_threshold_days
39
+ # Deliberately independent from plastic-intent-continuing's separate stale_threshold_days
40
40
  # config: that one is a proactive boot-time triage nudge, this is a board annotation.
41
41
  # Unifying the two is a follow-up, not this intent.
42
42
  STALE_DAYS = 30
@@ -163,7 +163,7 @@ end
163
163
 
164
164
  # True iff the savepoint ledger's last non-blank line shows real post-birth
165
165
  # activity, not just the one-line birth stamp every intent gets at creation
166
- # (scripts/new-intent's Bridge.append_savepoint call, stage "What"). Reads the
166
+ # (scripts/new-intent's Savepoint.append_savepoint call, stage "What"). Reads the
167
167
  # last line, extracts the stage token (second whitespace-separated field, same
168
168
  # ledger shape last_accessed_at already parses), and treats any stage other
169
169
  # than "What" as progress. Returns false when the file is missing/empty.
@@ -192,7 +192,7 @@ def parse_intent(store_info, dir_name, status_index)
192
192
  # Sentinel-aware presence for lifecycle files (intent 60b): a scaffolded
193
193
  # placeholder spec/plan/checklist/outcome reads as absent, so a freshly
194
194
  # scaffolded intent reports What/Why and is never marked completed/advanced.
195
- real = ->(f) { Bridge.stage_file_present?(File.join(dir, f)) }
195
+ real = ->(f) { Savepoint.stage_file_present?(File.join(dir, f)) }
196
196
  body = File.exist?(md) ? File.read(md) : ""
197
197
 
198
198
  status =
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # day-summary (intent 311): print the block SessionStart injects at boot
6
+ # (open items, the last five done, live auto intents, other active
7
+ # sessions). Prints nothing when there is nothing to say.
8
+ #
9
+ # Usage:
10
+ # day-summary [--store <dir>] [--day <YYYYMMDD>] [--session <id>] [--home <dir>]
11
+ #
12
+ # Defaults: the home is $PLASTIC_HOME (~/.plastic), the store is <home>/store,
13
+ # the day is today (local wall clock), the session is $CLAUDE_CODE_SESSION_ID.
14
+ #
15
+ # Exit codes: 0 done; 2 usage error.
16
+
17
+ require_relative "lib/session_ledger"
18
+ require_relative "lib/day_summary"
19
+
20
+ def usage_abort(message)
21
+ warn "day-summary: #{message}"
22
+ exit 2
23
+ end
24
+
25
+ def expand(path)
26
+ File.expand_path(path.to_s.sub(/\A~/, Dir.home))
27
+ end
28
+
29
+ def parse_args(argv)
30
+ opts = {}
31
+ i = 0
32
+ while i < argv.length
33
+ flag = argv[i]
34
+ usage_abort("unknown argument #{flag.inspect}") unless flag.start_with?("--")
35
+ usage_abort("#{flag} requires a value") if i + 1 >= argv.length
36
+ key = flag.delete_prefix("--").to_sym
37
+ usage_abort("unknown flag #{flag.inspect}") unless %i[store day session home].include?(key)
38
+ opts[key] = argv[i + 1]
39
+ i += 2
40
+ end
41
+ opts
42
+ end
43
+
44
+ opts = parse_args(ARGV)
45
+ home = opts[:home] ? expand(opts[:home]) : expand(ENV.fetch("PLASTIC_HOME", "~/.plastic"))
46
+ store = opts[:store] ? expand(opts[:store]) : File.join(home, "store")
47
+ day = opts[:day] || SessionLedger.day_id
48
+ usage_abort("--day must be eight digits parsing as a real date, got #{day.inspect}") unless SessionLedger.valid_day_id?(day)
49
+ session = SessionLedger.short_session_id(opts[:session], ENV["CLAUDE_CODE_SESSION_ID"])
50
+
51
+ text = DaySummary.build(store: store, day: day, session: session, home: home)
52
+ puts text unless text.empty?
53
+ exit 0