@zalom/plastic 2.0.0-alpha.23 → 2.0.0-alpha.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC.md +6 -5
- package/agents/plastic-advisor.md +1 -1
- package/agents/plastic-node-research.md +4 -4
- package/agents/plastic-node-verify.md +4 -4
- package/agents/plastic-node-work.md +4 -4
- package/package.json +1 -1
- package/scripts/dashboard.rb +1 -1
- package/scripts/end-intent +19 -16
- package/scripts/exec-worktree +5 -5
- package/scripts/hook-call-budget +6 -6
- package/scripts/hook-capture +15 -15
- package/scripts/hook-record +21 -14
- package/scripts/hook-savepoint +2 -1
- package/scripts/hook-session-start +9 -10
- package/scripts/lib/active_delivery.rb +44 -0
- package/scripts/lib/agent_models.rb +11 -0
- package/scripts/lib/arm.rb +41 -102
- package/scripts/lib/codex_adapter.rb +2 -2
- package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
- package/scripts/lib/day_summary.rb +19 -11
- package/scripts/lib/doctor_core.rb +1 -1
- package/scripts/lib/doctor_session_ledger.rb +3 -52
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/graph_measure_budget.rb +29 -28
- package/scripts/lib/handoff.rb +4 -8
- package/scripts/lib/harness_adapter.rb +6 -6
- package/scripts/lib/index_entry.rb +53 -0
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +143 -14
- package/scripts/lib/lock.rb +8 -9
- package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +4 -2
- package/scripts/lib/outcome_report.rb +1 -1
- package/scripts/lib/project_config.rb +45 -0
- package/scripts/lib/ready_set.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +1 -1
- package/scripts/lib/runner_absorb.rb +5 -5
- package/scripts/lib/runner_dispatch.rb +44 -44
- package/scripts/lib/runner_sweep.rb +4 -4
- package/scripts/lib/runner_until_empty.rb +1 -1
- package/scripts/lib/savepoint.rb +6 -7
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +20 -14
- package/scripts/lib/session_ledger.rb +44 -4
- package/scripts/lib/worktree.rb +24 -24
- package/scripts/lib/worktree_sweep.rb +3 -3
- package/scripts/{node-packet → node-input} +15 -15
- package/scripts/node-run +19 -19
- package/scripts/plastic-lock +33 -32
- package/scripts/runner +1 -1
- package/skills/auto/SKILL.md +7 -7
- package/skills/auto/references/end-tail.md +9 -11
- package/skills/auto/references/human-report-contract.md +1 -1
- package/skills/conventions/references/completion-and-done.md +9 -10
- package/skills/conventions/references/knowledge-graph.md +1 -1
- package/skills/conventions/references/locks-and-worktrees.md +10 -12
- package/skills/direct/SKILL.md +2 -2
- package/skills/doctor/SKILL.md +1 -1
- package/skills/intent-executing/SKILL.md +1 -1
- package/skills/releasing/SKILL.md +2 -2
- package/skills/releasing/references/promotion-and-tagging.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/templates/index.md +1 -1
- package/templates/project.yml +1 -1
- 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>/ #
|
|
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.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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>
|
|
@@ -9,7 +9,7 @@ description: |
|
|
|
9
9
|
approaches and kill criteria for architecture, one-way doors, or deadlocks. Model is set by config
|
|
10
10
|
(agents.models.claude.plastic-advisor); fable is the shipped default.
|
|
11
11
|
model: fable
|
|
12
|
-
effort:
|
|
12
|
+
effort: medium
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
You are the advisor, consulted for expensive reasoning per the shipped Advisor
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
22
|
-
inside the worktree it names, and commit them there. Touch no file outside the
|
|
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
package/scripts/dashboard.rb
CHANGED
|
@@ -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
|
|
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
|
package/scripts/end-intent
CHANGED
|
@@ -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,
|
|
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
|
|
50
|
-
#
|
|
51
|
-
#
|
|
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 (
|
|
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
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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
|
|
615
|
+
return explicit.to_s.strip unless blank?(explicit)
|
|
613
616
|
env = ENV["CLAUDE_CODE_SESSION_ID"]
|
|
614
|
-
return env.to_s.strip unless
|
|
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
|
-
|
|
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
|
|
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
|
|
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 " \
|
package/scripts/exec-worktree
CHANGED
|
@@ -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
|
-
#
|
|
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(
|
|
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
|
|
42
|
-
# resolved, or no code worktree recorded on
|
|
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)
|
package/scripts/hook-call-budget
CHANGED
|
@@ -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
|
|
25
|
-
# path into that prompt (
|
|
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
|
|
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 /
|
|
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
|
-
|
|
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 =
|
|
81
|
+
match = INPUT_PATH_RE.match(prompt)
|
|
82
82
|
return nil unless match
|
|
83
83
|
|
|
84
84
|
node = match[2]
|
package/scripts/hook-capture
CHANGED
|
@@ -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)
|
|
52
|
-
# Session start
|
|
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
|
|
56
|
-
# fires before PostToolUse, so capture is the FIRST
|
|
57
|
-
#
|
|
58
|
-
#
|
|
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
|
|
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)
|
|
68
|
+
# --- (b) a live delivery lock names this session (344 n2, D5, D12) ----------
|
|
70
69
|
skip_day_ledger = false
|
|
71
70
|
begin
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
package/scripts/hook-record
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
139
|
-
# Session start
|
|
140
|
-
# start never ran for this session (a `-p` print
|
|
141
|
-
# background job, an unregistered SessionStart hook), so
|
|
142
|
-
#
|
|
143
|
-
#
|
|
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
|
|
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
|
package/scripts/hook-savepoint
CHANGED
|
@@ -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
|
|
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/
|
|
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
|
|
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 =
|
|
246
|
-
parts <<
|
|
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,
|
|
393
|
-
#
|
|
394
|
-
#
|
|
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
|
|
@@ -102,6 +102,17 @@ module AgentModels
|
|
|
102
102
|
models_section(global_config, harness: harness).merge(models_section(project_config, harness: harness))
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
+
def effort_override_map(project_config: {}, global_config: {}, harness: "claude")
|
|
106
|
+
efforts_section(global_config, harness).merge(efforts_section(project_config, harness))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def efforts_section(config, harness)
|
|
110
|
+
agents = config.is_a?(Hash) ? config["agents"] : nil
|
|
111
|
+
efforts = agents.is_a?(Hash) ? agents["efforts"] : nil
|
|
112
|
+
section = efforts.is_a?(Hash) ? efforts[harness] : nil
|
|
113
|
+
section.is_a?(Hash) ? section : {}
|
|
114
|
+
end
|
|
115
|
+
|
|
105
116
|
# The model_reasoning_effort for a Plastic tier alias, or nil for any value that is not
|
|
106
117
|
# one of the three shipped aliases (the caller treats nil as a literal Codex model id).
|
|
107
118
|
def effort_for(value)
|