@zalom/plastic 2.0.0-alpha.23 → 2.0.0-alpha.24

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 (63) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-node-research.md +4 -4
  3. package/agents/plastic-node-verify.md +4 -4
  4. package/agents/plastic-node-work.md +4 -4
  5. package/package.json +1 -1
  6. package/scripts/dashboard.rb +1 -1
  7. package/scripts/end-intent +19 -16
  8. package/scripts/exec-worktree +5 -5
  9. package/scripts/hook-call-budget +6 -6
  10. package/scripts/hook-capture +15 -15
  11. package/scripts/hook-record +21 -14
  12. package/scripts/hook-savepoint +2 -1
  13. package/scripts/hook-session-start +9 -10
  14. package/scripts/lib/active_delivery.rb +44 -0
  15. package/scripts/lib/arm.rb +41 -102
  16. package/scripts/lib/codex_adapter.rb +2 -2
  17. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  18. package/scripts/lib/day_summary.rb +19 -11
  19. package/scripts/lib/doctor_session_ledger.rb +3 -52
  20. package/scripts/lib/exec_worktree.rb +24 -21
  21. package/scripts/lib/graph_measure_budget.rb +29 -28
  22. package/scripts/lib/handoff.rb +4 -8
  23. package/scripts/lib/harness_adapter.rb +6 -6
  24. package/scripts/lib/index_entry.rb +53 -0
  25. package/scripts/lib/insights.rb +1 -1
  26. package/scripts/lib/installer_core.rb +120 -7
  27. package/scripts/lib/lock.rb +8 -9
  28. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  29. package/scripts/lib/node_input_compatibility.rb +62 -0
  30. package/scripts/lib/node_ledger.rb +4 -2
  31. package/scripts/lib/outcome_report.rb +1 -1
  32. package/scripts/lib/project_config.rb +45 -0
  33. package/scripts/lib/ready_set.rb +1 -1
  34. package/scripts/lib/roadmap_savepoint.rb +1 -1
  35. package/scripts/lib/runner_absorb.rb +5 -5
  36. package/scripts/lib/runner_dispatch.rb +44 -44
  37. package/scripts/lib/runner_sweep.rb +4 -4
  38. package/scripts/lib/runner_until_empty.rb +1 -1
  39. package/scripts/lib/savepoint.rb +6 -7
  40. package/scripts/lib/scaffold_intent.rb +6 -3
  41. package/scripts/lib/session_close.rb +30 -28
  42. package/scripts/lib/session_git.rb +20 -14
  43. package/scripts/lib/session_ledger.rb +44 -4
  44. package/scripts/lib/worktree.rb +24 -24
  45. package/scripts/lib/worktree_sweep.rb +3 -3
  46. package/scripts/{node-packet → node-input} +15 -15
  47. package/scripts/node-run +19 -19
  48. package/scripts/plastic-lock +33 -32
  49. package/scripts/runner +1 -1
  50. package/skills/auto/SKILL.md +7 -7
  51. package/skills/auto/references/end-tail.md +9 -11
  52. package/skills/conventions/references/completion-and-done.md +9 -10
  53. package/skills/conventions/references/knowledge-graph.md +1 -1
  54. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  55. package/skills/direct/SKILL.md +2 -2
  56. package/skills/doctor/SKILL.md +1 -1
  57. package/skills/intent-executing/SKILL.md +1 -1
  58. package/skills/releasing/SKILL.md +2 -2
  59. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  60. package/skills/releasing/references/release-lines.md +1 -1
  61. package/templates/index.md +1 -1
  62. package/templates/project.yml +1 -1
  63. package/scripts/lib/bridge.rb +0 -116
package/PLASTIC.md CHANGED
@@ -83,14 +83,15 @@ runs the flow. A roadmap is an ordered, delivery-side collection; `plastic-roadm
83
83
  config.yml, projects.yml # Preferences; slug -> path
84
84
  store/ID--slug/ # Strategic intents
85
85
  store/.sessions/<YYYYMMDD>/ # Day ledger: <YYYYMMDD>.md, checklist.md, savepoint.md
86
- store/.tmp/<session>/ # current (the pointer), heartbeat; git-ignored
86
+ store/.tmp/<session>/ # heartbeat; git-ignored
87
87
  ~/.plastic/projects/{slug}/ # Project store: INDEX.md, AGENTS.md, roadmaps/, store/
88
88
  ```
89
89
 
90
- `<session>` is the first eight characters of the session id. The pointer holds a day id (the
91
- day ledger takes the record) or an intent id (an auto team owns the record); session start
92
- writes today's day id. The SessionStart hook picks the project store by matching the working
93
- directory against `projects.yml`; no Plastic files land in project code.
90
+ `<session>` is the first eight characters of the session id. Session start writes the heartbeat
91
+ file, proof only that the session started. The delivery lock (`delivery.lock` in the intent
92
+ directory) names the session delivering an intent, as its owner or a delegate; with no lock a
93
+ session records into its day ledger. SessionStart matches the working directory against
94
+ `projects.yml` to pick the project store.
94
95
 
95
96
  Naming: intents are `ID--three-to-five-words`, the file matching the directory
96
97
  (`1a1--slug/1a1--slug.md`); next id: `ruby ~/.plastic/scripts/folgezettel-id <parent_id>
@@ -2,7 +2,7 @@
2
2
  name: plastic-node-research
3
3
  description: |
4
4
  Dispatched for a research node by Plastic's node-graph runner. Given the
5
- packet path the dispatch line names, research the question it names by
5
+ node input path the dispatch line names, research the question it names by
6
6
  reading the repository, and reply with the YAML return the dispatch line
7
7
  names. Read-only: no Bash, no edit. Never spawned outside a runner
8
8
  dispatch.
@@ -13,13 +13,13 @@ tools:
13
13
  model: sonnet
14
14
  ---
15
15
 
16
- You are one node in Plastic's node-graph runner: one packet in, one YAML return out, no diff.
16
+ You are one node in Plastic's node-graph runner: one node input in, one YAML return out, no diff.
17
17
 
18
- Read the packet the dispatch line names; it is your whole input. Research the question it names
18
+ Read the node input the dispatch line names; it is your whole input. Research the question it names
19
19
  by reading the repository, never by running it. This role carries no `Bash`, because Claude
20
20
  Code's `tools:` field grants or withholds a whole tool name, never a command pattern, and a shell
21
21
  is a write whatever command it runs. `Read`, `Glob`, and `Grep` are enough to read every file the
22
- packet points at.
22
+ node input points at.
23
23
 
24
24
  A research node produces no diff. Put what you found in your return's `findings`, never in a code
25
25
  change.
@@ -2,7 +2,7 @@
2
2
  name: plastic-node-verify
3
3
  description: |
4
4
  Dispatched for a verify node by Plastic's node-graph runner. Given the
5
- packet path the dispatch line names, check the work it names by reading
5
+ node input path the dispatch line names, check the work it names by reading
6
6
  the repository, and reply with the YAML return the dispatch line names.
7
7
  Read-only: no Bash, no edit. Never spawned outside a runner dispatch.
8
8
  tools:
@@ -12,13 +12,13 @@ tools:
12
12
  model: opus
13
13
  ---
14
14
 
15
- You are one node in Plastic's node-graph runner: one packet in, one YAML return out, no diff.
15
+ You are one node in Plastic's node-graph runner: one node input in, one YAML return out, no diff.
16
16
 
17
- Read the packet the dispatch line names; it is your whole input. Verify what it asks you to
17
+ Read the node input the dispatch line names; it is your whole input. Verify what it asks you to
18
18
  verify by reading the repository, never by running it. This role carries no `Bash`, because
19
19
  Claude Code's `tools:` field grants or withholds a whole tool name, never a command pattern, and
20
20
  a shell is a write whatever command it runs. `Read`, `Glob`, and `Grep` are enough to read every
21
- file the packet points at.
21
+ file the node input points at.
22
22
 
23
23
  A verify node produces no diff. If verifying the work would require changing a file, that is not
24
24
  this role's job: say so in your return instead of reaching for a tool that could make the change.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: plastic-node-work
3
3
  description: |
4
- Dispatched for a work node by Plastic's node-graph runner. Given the packet
4
+ Dispatched for a work node by Plastic's node-graph runner. Given the node input
5
5
  path the dispatch line names, make the node's code changes inside the node
6
6
  worktree, commit them there, and reply with the YAML return the dispatch
7
7
  line names. Never spawned outside a runner dispatch.
@@ -15,11 +15,11 @@ tools:
15
15
  model: sonnet
16
16
  ---
17
17
 
18
- You are one node in Plastic's node-graph runner: one packet in, your files and your commit and
18
+ You are one node in Plastic's node-graph runner: one node input in, your files and your commit and
19
19
  one YAML return out.
20
20
 
21
- Read the packet the dispatch line names; it is your whole input. Make the changes it asks for,
22
- inside the worktree it names, and commit them there. Touch no file outside the packet's `files:`
21
+ Read the node input the dispatch line names; it is your whole input. Make the changes it asks for,
22
+ inside the worktree it names, and commit them there. Touch no file outside the node input's `files:`
23
23
  list, write to no other node's worktree, and write no ledger line yourself: the runner's own
24
24
  gates read your commit and your return, never your word.
25
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.23",
3
+ "version": "2.0.0-alpha.24",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -100,7 +100,7 @@ end
100
100
  # instead, with no end-of-line requirement, so trailing note prose is irrelevant. The
101
101
  # separator there may be a real em dash (U+2014, INDEX.md's normal on-write convention,
102
102
  # built from the codepoint so this source line stays em-dash free) or a plain hyphen
103
- # (end-intent's own Bridge.index_entry_match accepts either on read). Because regex
103
+ # (end-intent's own IndexEntry.match accepts either on read). Because regex
104
104
  # alternation is leftmost-first, this only ever matches the date immediately after the
105
105
  # link. It never continues scanning into the note prose, so a second date mentioned
106
106
  # later in a note's free text cannot be mistaken for the completion date. The id class
@@ -37,7 +37,7 @@
37
37
  # 2. Move the intent's INDEX.md line from `## Active` into `## Completed`
38
38
  # (delivered) or `## Abandoned` (abandoned), dated today, appending
39
39
  # --index-note (if given). Accepts a real em dash OR a plain hyphen as
40
- # the id/title separator on READ (shared matcher, Bridge.index_entry_match);
40
+ # the id/title separator on READ (shared matcher, IndexEntry.match);
41
41
  # always EMITS the real em dash on write. An id that resolves to neither
42
42
  # `## Active` nor the terminal section is a loud failure (exit 1); an id
43
43
  # already correctly in the terminal section is a quiet, idempotent success.
@@ -46,10 +46,9 @@
46
46
  # 5. Disarm (intent 188, D2/D16; intent 307): check the code worktree (derived
47
47
  # from projects.yml and the intent id, Arm.worktree_block) for uncommitted
48
48
  # changes first (exit 5 if dirty, unless --discard-worktree-changes); call the
49
- # disarm seam (default Arm.disarm: worktree released, lock cleared, the session
50
- # pointer back on the day ledger); verify the durable lock file is actually gone
51
- # afterward, and release it directly when a foreign-keyed disarm left it (the
52
- # /tmp bridge this step once read was removed in 2.0, intent 307).
49
+ # disarm seam (default Arm.disarm: worktree released, lock cleared); verify
50
+ # the durable lock file is actually gone afterward, and release it directly
51
+ # when a foreign-keyed disarm left it.
53
52
  #
54
53
  # Exit codes:
55
54
  # 0 ok - the intent is closed and no delivery.lock remains.
@@ -57,7 +56,7 @@
57
56
  # nor the terminal section (D11). Deliberately double duty; see plan.md.
58
57
  # 2 retired in 2.0 (intent 308): the outcome.md guard reports and proceeds.
59
58
  # 3 steps 1-4 already committed, but the delivery lock genuinely could not
60
- # be cleared (not a bridge cache miss; see step 5 above).
59
+ # be cleared (see step 5 above).
61
60
  # 4 pre-flight refusal: a FRESH foreign lock is held by a live,
62
61
  # non-delegate session; a FRESH lock that will not parse (corrupt); or a
63
62
  # lock file with no resolvable session identity at all. Authors nothing.
@@ -73,8 +72,8 @@ require "fileutils"
73
72
  require "date"
74
73
  require "open3"
75
74
  require "pathname"
76
- require_relative "lib/bridge"
77
75
  require_relative "lib/arm"
76
+ require_relative "lib/index_entry"
78
77
  require_relative "lib/savepoint"
79
78
  require_relative "lib/lock"
80
79
  require_relative "lib/intent_validator"
@@ -89,7 +88,7 @@ DISPOSITIONS = %w[delivered abandoned].freeze
89
88
  # dash (U+2014), the existing INDEX.md convention (store files are exempt from
90
89
  # the no-em-dash shipped-file rule). Built from the codepoint, not a literal
91
90
  # byte in this source file, so the script's own source stays em-dash free.
92
- # READING accepts a plain hyphen too, via the shared Bridge.index_entry_match
91
+ # READING accepts a plain hyphen too, via the shared IndexEntry.match
93
92
  # (intent 188, D9/D12): see move_index_to_terminal below.
94
93
  EM_DASH = "\u2014"
95
94
 
@@ -99,6 +98,10 @@ class UnresolvedIndexEntry < StandardError; end
99
98
 
100
99
  # --- Explicit flag parsing (no eval, no global injection) -------------------
101
100
 
101
+ def blank?(value)
102
+ value.nil? || value.to_s.strip.empty?
103
+ end
104
+
102
105
  def parse_args(argv)
103
106
  opts = {
104
107
  store: nil, id: nil, disposition: nil, index: nil,
@@ -438,10 +441,10 @@ def section_stop(lines, heading_idx)
438
441
  stop
439
442
  end
440
443
 
441
- # Shared matcher (Bridge.index_entry_match, intent 188 D12): accepts a real em
444
+ # Shared matcher (IndexEntry.match, intent 188 D12): accepts a real em
442
445
  # dash OR a plain hyphen as the id/title separator on READ.
443
446
  def active_line_id(line)
444
- m = Bridge.index_entry_match(line)
447
+ m = IndexEntry.match(line)
445
448
  m && m[1]
446
449
  end
447
450
 
@@ -520,7 +523,7 @@ def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
520
523
  raise UnresolvedIndexEntry, "INDEX.md has no #{target_heading} heading; cannot complete the move"
521
524
  end
522
525
 
523
- m = Bridge.index_entry_match(lines[entry_idx])
526
+ m = IndexEntry.match(lines[entry_idx])
524
527
  title, link = m[2], m[3]
525
528
  note_suffix = (index_note && !index_note.to_s.strip.empty?) ? " #{index_note.to_s.strip}" : ""
526
529
  new_entry = "- [#{id} #{EM_DASH} #{title}](#{link}) #{EM_DASH} #{today}#{note_suffix}\n"
@@ -609,13 +612,13 @@ end
609
612
  # exclusion with a raw backtrace. nil is the only safe "could not resolve"
610
613
  # signal; main refuses explicitly on it rather than ever reaching takeover.
611
614
  def resolve_end_session(explicit, intent_dir)
612
- return explicit.to_s.strip unless Bridge.blank?(explicit)
615
+ return explicit.to_s.strip unless blank?(explicit)
613
616
  env = ENV["CLAUDE_CODE_SESSION_ID"]
614
- return env.to_s.strip unless Bridge.blank?(env)
617
+ return env.to_s.strip unless blank?(env)
615
618
  lock = Lock.read(intent_dir)
616
619
  return nil unless lock
617
620
  owner = lock["owner_session"].to_s
618
- Bridge.blank?(owner) ? nil : owner
621
+ blank?(owner) ? nil : owner
619
622
  end
620
623
 
621
624
  # Read-only pre-flight verdict (D4). Returns [:proceed, lock_or_nil],
@@ -663,7 +666,7 @@ end
663
666
  def run_disarm(intent_dir, id, session, discard_worktree_changes:,
664
667
  worktree_reader: ->(dir) { Arm.worktree_block(intent_dir: dir) },
665
668
  disarm: ->(sess, dir) { Arm.disarm(intent_dir: dir, session: sess) })
666
- return :ok if Bridge.blank?(session)
669
+ return :ok if blank?(session)
667
670
 
668
671
  block = worktree_reader.call(intent_dir)
669
672
  worktree_code = block.is_a?(Hash) ? block["code"] : nil
@@ -788,7 +791,7 @@ def main(argv)
788
791
  # Lock.takeover (blank session reaches Lock.acquire's blank? guard AFTER the
789
792
  # existing lock file was already deleted) or silently mis-arbitrate. Refuse
790
793
  # rather than guess; nothing has been written yet.
791
- if Bridge.blank?(session) && File.exist?(Lock.path(intent_dir))
794
+ if blank?(session) && File.exist?(Lock.path(intent_dir))
792
795
  warn "end-intent: a delivery lock exists at #{Lock.path(intent_dir)} but no session " \
793
796
  "identity could be resolved (--session, CLAUDE_CODE_SESSION_ID, and the lock's " \
794
797
  "own recorded owner are all blank); refusing rather than guessing. Pass " \
@@ -21,15 +21,15 @@
21
21
  # (--session, else CLAUDE_CODE_SESSION_ID, else the existing delivery.lock's own
22
22
  # recorded owner_session when non-blank, else nil; the lock-owner fallback IS
23
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
24
+ # Derive the worktree block from projects.yml and the intent id (Arm.worktree_block).
25
+ # No code worktree on
26
26
  # disk means nothing was provisioned (exit 0).
27
27
  # 1. The dirty-worktree guard (delivered only): git status --porcelain on the code
28
28
  # worktree. Not clean, or the check itself fails, fails CLOSED (exit 3), mirroring
29
29
  # end-intent's dirty-worktree handling. abandoned skips this guard entirely: the
30
30
  # branch survives and can be reclaimed, so a dirty worktree is not a reason to
31
31
  # refuse.
32
- # 2. Finish: Worktree.finish(bridge_data, home:, runner:, merge: disposition ==
32
+ # 2. Finish: Worktree.finish(delivery, home:, runner:, merge: disposition ==
33
33
  # "delivered"). Worktree.finish is fail-open and never raises, so failure is
34
34
  # detected by checking, for a delivered disposition only, whether the worktree
35
35
  # directory still exists on disk afterward (release removes it on success).
@@ -38,8 +38,8 @@
38
38
  # one concern per script (spec D8).
39
39
  #
40
40
  # Exit codes:
41
- # 0 finished (merged or removed per disposition), or nothing to finish (no bridge
42
- # resolved, or no code worktree recorded on the bridge)
41
+ # 0 finished (merged or removed per disposition), or nothing to finish (no delivery
42
+ # resolved, or no code worktree recorded on it)
43
43
  # 1 usage or path-resolution failure
44
44
  # 2 retired in 2.0 (intent 302): was the order precondition; never emitted, never reused
45
45
  # 3 the worktree is not clean, or the merge/removal did not complete (delivered only)
@@ -21,10 +21,10 @@
21
21
  # transcript, at <dirname(transcript_path)>/<session_id>/subagents/
22
22
  # agent-<agent_id>.jsonl (the main transcript_path plus the main session_id
23
23
  # plus the subagent's own agent_id): its first user record carries the
24
- # dispatch prompt, and the lead/spawn block always pastes the node's packet
25
- # path into that prompt (PACKET_PATH_RE). The packet path names the intent
24
+ # dispatch prompt, and the lead/spawn block always pastes the node's input
25
+ # path into that prompt (INPUT_PATH_RE). The node input path names the intent
26
26
  # directory directly (its grandparent), so this hook never scans the store to
27
- # find it. No packet path in that first prompt means allow.
27
+ # find it. No node input path in that first prompt means allow.
28
28
  #
29
29
  # The cap applies only when that node's LATEST transition line in
30
30
  # <intent>/savepoint.md is `running` and carries `calls=`; a later `done` or
@@ -47,10 +47,10 @@ module HookCallBudget
47
47
 
48
48
  RETURN_INSTRUCTION = "commit what is green and return failed_verification reason=call_budget"
49
49
 
50
- # A node id, then "--a", then an attempt number, inside a /packets/
50
+ # A node id, then "--a", then an attempt number, inside a /attempts/
51
51
  # directory: the exact shape RunnerDispatch's spawn block pastes into a
52
52
  # subagent's own first prompt. Captures [full_path, node].
53
- PACKET_PATH_RE = %r{(\S+/packets/([a-z]{1,2}\d+)--a\d+\.packet)}.freeze
53
+ INPUT_PATH_RE = %r{(\S+/attempts/([a-z]{1,2}\d+)--a\d+\.input)}.freeze
54
54
 
55
55
  FIRST_RECORD_SCAN_LIMIT = 20
56
56
 
@@ -78,7 +78,7 @@ module HookCallBudget
78
78
  end
79
79
  return nil unless prompt
80
80
 
81
- match = PACKET_PATH_RE.match(prompt)
81
+ match = INPUT_PATH_RE.match(prompt)
82
82
  return nil unless match
83
83
 
84
84
  node = match[2]
@@ -15,6 +15,7 @@ require "time"
15
15
  require "fileutils"
16
16
  require_relative "lib/session_ledger"
17
17
  require_relative "lib/dashboard_banner"
18
+ require_relative "lib/active_delivery"
18
19
 
19
20
  raw = begin
20
21
  $stdin.read
@@ -48,32 +49,31 @@ def truncate(text, max)
48
49
  "#{text[0, max - 3]}..."
49
50
  end
50
51
 
51
- # --- (a) tmp root + heartbeat, guarded on the pointer (spec D9) ------------
52
- # Session start writes the `current` pointer; a capture with no pointer yet
52
+ # --- (a) heartbeat, guarded on the session tmp dir already existing --------
53
+ # Session start creates `.tmp/<sid>/`; a capture with no such directory yet
53
54
  # means session start never ran for this session at all (a `-p` print
54
55
  # session, a resumed background job, an unregistered SessionStart hook), so
55
- # neither the per-session dir nor its heartbeat is created. UserPromptSubmit
56
- # fires before PostToolUse, so capture is the FIRST creator of every orphan
57
- # `.tmp/<sid>/` this guard prevents; hook-record carries the same guard for
58
- # its own (later) heartbeat write.
56
+ # neither the directory nor its heartbeat is created HERE either.
57
+ # UserPromptSubmit fires before PostToolUse, so capture is the FIRST place
58
+ # this guard matters; hook-record carries the same guard for its own
59
+ # (later) heartbeat write. (344 n2, D4-D5)
59
60
  begin
60
- if File.exist?(SessionLedger.pointer_path(store, sid))
61
- SessionLedger.ensure_tmp_root(store)
62
- FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
61
+ if Dir.exist?(SessionLedger.session_tmp_dir(store, sid))
63
62
  File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
64
63
  end
65
64
  rescue StandardError
66
65
  nil
67
66
  end
68
67
 
69
- # --- (b) read the pointer -----------------------------------------------
68
+ # --- (b) a live delivery lock names this session (344 n2, D5, D12) ----------
70
69
  skip_day_ledger = false
71
70
  begin
72
- pointer = SessionLedger.pointer_path(store, sid)
73
- if File.exist?(pointer)
74
- current = File.read(pointer).strip
75
- skip_day_ledger = !current.empty? && current != today
76
- end
71
+ full_session = session_id.empty? ? ENV["CLAUDE_CODE_SESSION_ID"].to_s : session_id
72
+ skip_day_ledger = !ActiveDelivery.resolve(
73
+ global_store: store,
74
+ project_roots: ActiveDelivery.project_roots(plastic_home),
75
+ session: full_session
76
+ ).nil?
77
77
  rescue StandardError
78
78
  nil
79
79
  end
@@ -16,6 +16,7 @@ require "fileutils"
16
16
  require_relative "lib/savepoint"
17
17
  require_relative "lib/lock"
18
18
  require_relative "lib/session_ledger"
19
+ require_relative "lib/active_delivery"
19
20
 
20
21
  raw = begin
21
22
  $stdin.read
@@ -80,11 +81,20 @@ unless skip_day_ledger
80
81
  sid = SessionLedger.short_session_id(nil, session_id)
81
82
  today = SessionLedger.day_id
82
83
 
84
+ # A live delivery lock naming this session's full id skips promotion
85
+ # (344 n2, D5, D12): the lock, not the retired pointer, decides.
83
86
  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
87
+ unless session_id.empty?
88
+ begin
89
+ held = ActiveDelivery.resolve(
90
+ global_store: store,
91
+ project_roots: ActiveDelivery.project_roots(plastic_home),
92
+ session: session_id
93
+ )
94
+ proceed = held.nil?
95
+ rescue StandardError
96
+ proceed = true
97
+ end
88
98
  end
89
99
 
90
100
  if proceed
@@ -135,19 +145,16 @@ unless skip_day_ledger
135
145
  end
136
146
  end
137
147
 
138
- # --- (d) heartbeat, guarded on the pointer (spec D9) -----------------------
139
- # Session start writes the `current` pointer; with no pointer at all, session
140
- # start never ran for this session (a `-p` print session, a resumed
141
- # background job, an unregistered SessionStart hook), so neither the
142
- # per-session dir nor its heartbeat is created here -- guarding only the
143
- # write would still leave an empty directory that heartbeat_age's mtime
144
- # fallback reports as an orphan just the same.
148
+ # --- (d) heartbeat, guarded on the session tmp dir (344 n2, D4) ------------
149
+ # Session start creates the per-session tmp directory; with no such
150
+ # directory at all, session start never ran for this session (a `-p` print
151
+ # session, a resumed background job, an unregistered SessionStart hook), so
152
+ # the heartbeat is not written and no directory is created here either --
153
+ # the session tmp directory, created by session start, is the guard.
145
154
  begin
146
155
  store ||= File.join(plastic_home, "store")
147
156
  sid ||= SessionLedger.short_session_id(nil, session_id)
148
- if File.exist?(SessionLedger.pointer_path(store, sid))
149
- SessionLedger.ensure_tmp_root(store)
150
- FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
157
+ if Dir.exist?(SessionLedger.session_tmp_dir(store, sid))
151
158
  File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
152
159
  end
153
160
  rescue StandardError
@@ -4,7 +4,8 @@
4
4
 
5
5
  # hook-savepoint (intent 311): the PreCompact hook body. Reads the harness
6
6
  # payload from stdin (session_id, cwd), takes the Plastic home from argv,
7
- # writes this session's hand-off for the pointer's day, and prints one
7
+ # writes this session's hand-off for the session's day from the day ledger,
8
+ # and prints one
8
9
  # message: the fixed text, plus the written file's path when a session
9
10
  # resolved (both harnesses relay the same launcher and the Codex pin feeds
10
11
  # both sides the same payload, so the two stay equal). Always exits 0,
@@ -7,13 +7,12 @@ require "json"
7
7
  require "date"
8
8
  require "yaml"
9
9
  require "fileutils"
10
- require_relative "lib/bridge"
11
10
  require_relative "lib/boot_banner"
12
11
  require_relative "lib/qmd_sync"
13
12
  require_relative "lib/doctor_core"
14
13
  require_relative "lib/session_ledger"
15
14
  require_relative "lib/day_summary"
16
- require_relative "lib/packet_wrapper"
15
+ require_relative "lib/data_boundary"
17
16
  require_relative "lib/active_delivery"
18
17
  require_relative "lib/runner_core"
19
18
  require_relative "lib/runner_watch"
@@ -236,14 +235,14 @@ begin
236
235
  if qmd_status[:present]
237
236
  # C23 (intent 341, G8): a QMD hit is untrusted text, so it never sits
238
237
  # loose in additionalContext alongside the owner's own banners; it
239
- # rides inside PacketWrapper's data boundary.
238
+ # rides inside DataBoundary's data boundary.
240
239
  qmd_line = if qmd_status[:all_registered]
241
240
  "QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
242
241
  else
243
242
  "QMD detected — run `qmd-sync register --all` to index your Plastic stores for search."
244
243
  end
245
- token = PacketWrapper.boundary_token([qmd_line])
246
- parts << PacketWrapper.wrap(qmd_line, label: "qmd-hit", source: "qmd-sync", token: token).rstrip
244
+ token = DataBoundary.boundary_token([qmd_line])
245
+ parts << DataBoundary.wrap(qmd_line, label: "qmd-hit", source: "qmd-sync", token: token).rstrip
247
246
  end
248
247
  rescue Exception
249
248
  # Any failure (timeout, missing binary, parse error) — stay silent, never crash.
@@ -389,9 +388,11 @@ begin
389
388
  end
390
389
  end
391
390
 
392
- # --- Session day ledger: open or join today, write the per-session pointer
393
- # and heartbeat (intent 298, spec D4). Best-effort: any failure here degrades
394
- # to no ledger context line, never blocks the session.
391
+ # --- Session day ledger: open or join today, create the tmp dir and write
392
+ # the heartbeat (344 n2, spec D4). The per-session state retired: hooks
393
+ # now ask ActiveDelivery.resolve for a live delivery lock instead.
394
+ # Best-effort: any failure here degrades to no ledger context line, never
395
+ # blocks the session.
395
396
  unless subagent_session
396
397
  begin
397
398
  templates = File.expand_path("../templates", __dir__)
@@ -409,8 +410,6 @@ begin
409
410
  SessionLedger.ensure_tmp_root(store_dir)
410
411
  FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
411
412
  File.write(SessionLedger.heartbeat_path(store_dir, sid), "#{Time.now.utc.iso8601}\n")
412
- pointer = SessionLedger.pointer_path(store_dir, sid)
413
- File.write(pointer, "#{day}\n") unless File.exist?(pointer)
414
413
 
415
414
  checklist = SessionLedger.checklist_path(store_dir, day)
416
415
  open_count = 0
@@ -1,6 +1,7 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "yaml"
4
5
  require_relative "lock"
5
6
 
6
7
  # ActiveDelivery (intent 340b, G7c, n4, D10): which intent a session is
@@ -58,4 +59,47 @@ module ActiveDelivery
58
59
  def blank?(value)
59
60
  value.nil? || value.to_s.strip.empty?
60
61
  end
62
+
63
+ DEFAULT_PROJECT_ROOTS = ["~/.plastic/projects"].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
61
105
  end