@zalom/plastic 1.0.0-beta.24 → 1.0.0-beta.26
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 +101 -31
- package/agents/plastic-intent-curator.md +4 -1
- package/hooks/code-gate +10 -1
- package/hooks/hooks.json +3 -3
- package/hooks/lock-gate +10 -1
- package/package.json +1 -1
- package/scripts/doctor.rb +225 -3
- package/scripts/hook-bash-gate +21 -3
- package/scripts/hook-create-gate +43 -7
- package/scripts/hook-gate-check +10 -0
- package/scripts/hook-lock-gate +14 -3
- package/scripts/hook-retrieval-gate +29 -17
- package/scripts/lib/bridge.rb +231 -73
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/installer_core.rb +8 -62
- package/scripts/lib/lock.rb +193 -0
- package/scripts/lib/power_tools.rb +11 -12
- package/scripts/lib/worktree.rb +21 -46
- package/scripts/plastic-lock +120 -0
- package/skills/auto/SKILL.md +40 -11
- package/skills/executing-plan/SKILL.md +4 -4
- package/skills/intent-curator/SKILL.md +4 -3
- package/skills/intent-starting/SKILL.md +16 -3
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +5 -1
- package/templates/outcome.md +3 -0
package/PLASTIC.md
CHANGED
|
@@ -283,32 +283,15 @@ hygiene after each intent. Advisory self-check, not hard-verifiable.
|
|
|
283
283
|
|
|
284
284
|
## Retrieval Gate
|
|
285
285
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
reindex is fired so the next turn enforces against a fresh index; reindex is never
|
|
296
|
-
synchronous. When QMD is absent, the search is allowed.
|
|
297
|
-
- Reading a known target (the Read tool, bash `cat`/`head`/`tail`) and structural discovery
|
|
298
|
-
(the Glob tool, bash `find`/`ls`) are always allowed, including over the store. QMD cannot
|
|
299
|
-
list directories or hand back one specific file, so these are never gated.
|
|
300
|
-
- Code is never hard-gated here. Symbolic code navigation via Serena is a soft prompt mandate
|
|
301
|
-
(the UserPromptSubmit power-tools hook), not a block: content grep over code is allowed,
|
|
302
|
-
because Serena navigates symbols and cannot grep arbitrary strings.
|
|
303
|
-
- QMD failure model. Absent or stale degrades to allow (stale also fires the background
|
|
304
|
-
reindex). A broken QMD, where the freshness probe errors or times out, also fails open, and
|
|
305
|
-
the hook emits a one-line warning so a degraded QMD is visible rather than silent.
|
|
306
|
-
- Bypass: append a trailing `# qmd-ok` shell comment to a Bash command when you attempted
|
|
307
|
-
discovery and it did not serve you (no hits, or results that do not answer your need by your
|
|
308
|
-
reading of the snippets, not their score). A quoted or echoed occurrence does not bypass.
|
|
309
|
-
Bypasses are logged. The gate enforces that discovery was attempted, never that it succeeded.
|
|
310
|
-
- Scope: only the agent's tool calls. Ruby `File.read` inside a script is invisible to the gate
|
|
311
|
-
and is out of scope by design.
|
|
286
|
+
Advisory. Hard gates guard writes, locks, and structure, never reads or searches. Read,
|
|
287
|
+
Grep, Glob, and bash search are always allowed, including over the stores. When QMD is
|
|
288
|
+
present and fresh, a content search over store markdown receives an advisory hint pointing
|
|
289
|
+
at `qmd search` alongside its result; when QMD is present but stale, a background reindex
|
|
290
|
+
fires so the next turn's hint runs against a fresh index (never synchronous). QMD and
|
|
291
|
+
Serena are recommendations, not obligations: the UserPromptSubmit power-tools hook appends
|
|
292
|
+
one recommendation line per present tool. The legacy trailing `# qmd-ok` token is still
|
|
293
|
+
accepted on Bash commands and simply silences the hint. Scope stays the agent's own tool
|
|
294
|
+
calls; Ruby `File.read` inside a script is invisible to the hook by design.
|
|
312
295
|
|
|
313
296
|
## Context-economy measurement buckets (84a)
|
|
314
297
|
|
|
@@ -332,11 +315,28 @@ Hard blocking — hooks exit code 2 on gate failure.
|
|
|
332
315
|
|
|
333
316
|
## Delivery Isolation and the Single-Owner Lock
|
|
334
317
|
|
|
335
|
-
Exactly one session or agent develops an intent's delivery at a time. Ownership is
|
|
336
|
-
session
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
318
|
+
Exactly one session or agent develops an intent's delivery at a time. Ownership is
|
|
319
|
+
session-keyed and durable: arming acquires `delivery.lock` inside the intent directory
|
|
320
|
+
(atomically, O_EXCL), recording the owner session, the host, the acquired-at time, a
|
|
321
|
+
delegates list, and the lock type. Liveness is a lease: the owner's hooks refresh the lock
|
|
322
|
+
file's mtime on tool activity, and the lock counts as stale only when that heartbeat is
|
|
323
|
+
older than the TTL. No process id is consulted anywhere. The /tmp session bridge is a cache
|
|
324
|
+
of this state; on any disagreement, or when the bridge is missing, the lock file wins.
|
|
325
|
+
Another session that finds a fresh lock backs off; a stale lock is reclaimed only by
|
|
326
|
+
explicit takeover, which replaces the lock and appends an audit line to the intent's
|
|
327
|
+
savepoint.md. Subagents spawned by the owner write under the owner's lock once registered
|
|
328
|
+
as delegates. Disarm clears the lock; the End tail is ordered: verify, merge and remove
|
|
329
|
+
worktrees, clear the lock, and only then is the bridge purge-eligible. Repair is one
|
|
330
|
+
idempotent function with two entry points: the `plastic-lock` command (status, fix,
|
|
331
|
+
release, reclaim, delegate) and `/plastic-intent-starting`, so boarding self-heals. This is
|
|
332
|
+
mandatory, not a convention.
|
|
333
|
+
|
|
334
|
+
Two locks share this schema (the two-lock doctrine): `delivery.lock` (exclusive, one owner
|
|
335
|
+
plus delegates) and the future `maintenance.lock` (short TTL, structural move-and-record
|
|
336
|
+
only). They are mutually exclusive in either direction; maintenance is allowed at any
|
|
337
|
+
lifecycle stage provided no delivery lock is held. Intent 108 ships the delivery lock and
|
|
338
|
+
the mutual-exclusion seam; the maintenance lock implementation follows intent 93 in a
|
|
339
|
+
chained intent.
|
|
340
340
|
|
|
341
341
|
Every code-touching intent gets its own git worktree named `{id}--{slug}`, and all code edits
|
|
342
342
|
for that intent happen only inside it. Plastic provisions the worktree deterministically: it
|
|
@@ -350,6 +350,76 @@ Provisioning fails open for intents that touch no project code (pure research or
|
|
|
350
350
|
intents in the global store, or a non-git repo): those get the lock only, and the worktree
|
|
351
351
|
block stays unprovisioned. The fail-open path is always logged, never silent.
|
|
352
352
|
|
|
353
|
+
### Intent delivery, station by station
|
|
354
|
+
|
|
355
|
+
How one intent travels from boarding to Done, and what the lock, bridge, and gates do at
|
|
356
|
+
each station.
|
|
357
|
+
|
|
358
|
+
| Station | Delivered artifact | Lock and bridge steps | Pre-stage gate | Post-stage record |
|
|
359
|
+
|---|---|---|---|---|
|
|
360
|
+
| Start (board) | none (a procedure, not a stage) | `plastic-lock fix` self-heals stale, corrupt, or legacy state; arm acquires `delivery.lock` (O_EXCL, session-keyed), provisions the code worktree, writes the bridge cache | lock-gate denies any write into an active intent dir without this intent's lock; every deny names the resolving command | savepoint confirms the boarding station |
|
|
361
|
+
| What (create) | `<id>--<slug>.md`, born complete | no lock yet; no bridge | create-gate validates the proposed intent content (Write, Edit, and MCP edits) | savepoint `What` line; intent listed in INDEX `## Active` |
|
|
362
|
+
| Why | `spec.md` | owner writes refresh the lease (lock file mtime heartbeat) | gate-check requires the intent file with `## Intent` before spec.md; lock-gate admits only the owner or a delegate | savepoint `Why started`, `Why spec.md created` |
|
|
363
|
+
| How | `plan.md`, `actions/`, `checklist.md` | heartbeat on writes; the code gate stays closed until plan.md plus checklist.md exist | gate-check requires spec.md before plan.md, and plan.md plus actions/ before checklist.md | savepoint `How started`, `How plan.md created`, `How checklist.md created`, `Exec started` |
|
|
364
|
+
| Exec | code on the intent branch, checklist checked off | heartbeat; code edits confined to the provisioned worktree; delegates write under the owner's lock; bash, interpreter, and MCP writes gated the same way | code-gate, worktree-gate, bash-gate, lock-gate | checklist boxes; savepoint milestones |
|
|
365
|
+
| End (done) | mandatory `outcome.md` (`disposition: delivered\|abandoned`), INDEX moves to Completed or Abandoned | ordered End tail: verify, merge and remove worktrees, disarm clears `delivery.lock`, then the bridge is purge-eligible, and the QMD reindex runs LAST (after purge) | gate-check blocks outcome.md while checklist items are unchecked | savepoint `Done delivered` (or `abandoned`); takeover audits, if any, remain in savepoint.md |
|
|
366
|
+
| Maintenance (any stage) | `revisions.md` move-and-record entries | future `maintenance.lock` (short TTL), mutually exclusive with `delivery.lock` in either direction; 108 ships the schema seam only, the implementation follows intent 93 in a chained intent | acquisition refuses while the other lock type is fresh; a terminal intent with no lock held is read-only | dated, rule-tagged `revisions.md` entry; savepoint untouched |
|
|
367
|
+
|
|
368
|
+
### What "intent done" means (intent 93)
|
|
369
|
+
|
|
370
|
+
Done is one law with three signals, and they must agree. INDEX `## Completed` /
|
|
371
|
+
`## Abandoned` is the single canonical terminal marker: it is the store-wide ledger a fresh
|
|
372
|
+
session reads first, so it wins on any conflict. `outcome.md` is the "deliverable exists"
|
|
373
|
+
signal, and the savepoint `Done delivered|abandoned` line is the audit echo. All three must
|
|
374
|
+
agree; when they disagree, INDEX is authoritative and `doctor` flags the mismatch (the
|
|
375
|
+
`done_signals` check: `outcome.md` real but still under `## Active`, or terminal without a
|
|
376
|
+
real `outcome.md`, or a terminal intent whose savepoint carries no `Done` line).
|
|
377
|
+
|
|
378
|
+
`outcome.md` is mandatory at every terminal transition, delivered and abandoned alike. It
|
|
379
|
+
self-declares its disposition through a `disposition: delivered|abandoned` frontmatter
|
|
380
|
+
header. The delivered path authors it with the result; the abandoned path authors it with
|
|
381
|
+
the abandonment reason and no longer leaves the scaffolded placeholder sentinel in place.
|
|
382
|
+
|
|
383
|
+
The canonical End tail runs in this order, and the QMD reindex is always LAST, after the
|
|
384
|
+
purge: `outcome.md -> INDEX terminal -> savepoint Done -> commit -> disarm (Worktree.release
|
|
385
|
+
-> Lock.release -> purge) -> QMD reindex`. Running the reindex last keeps the index from
|
|
386
|
+
ever referencing a bridge or lock that disarm is about to remove.
|
|
387
|
+
|
|
388
|
+
The post-done access window is lock-bounded: `[INDEX terminal -> Lock.release]`. Through it
|
|
389
|
+
the completing session keeps full read and write access to the terminal directory and no
|
|
390
|
+
purge can fire (108's lock-held keep-guard keeps the bridge while `delivery.lock` exists).
|
|
391
|
+
Once the lock is released the window closes: the bridge becomes purge-eligible and the
|
|
392
|
+
directory is frozen. A crash mid-tail is recovered by stale-lock reclaim plus finishing the
|
|
393
|
+
tail; `doctor` surfaces this as a "stalled completion" (terminal in INDEX but the lock is
|
|
394
|
+
still present or stale). Finishing the tail is FINISHING a completion, never a reactivation:
|
|
395
|
+
a done intent is never moved back to `## Active`.
|
|
396
|
+
|
|
397
|
+
Terminal immutability (the contract intent 112 enforces): a terminal directory is writable
|
|
398
|
+
ONLY while a lock is held. The delivery lock covers the completing session's End tail up to
|
|
399
|
+
`Lock.release`; the maintenance lock covers sanctioned structural move-and-record edits
|
|
400
|
+
after. Terminal with no lock held is frozen. There are only two locks in the system,
|
|
401
|
+
delivery and maintenance (108 D11). This governs WRITES only: reads of a terminal intent are
|
|
402
|
+
always allowed and unbounded (curator reindex, dashboards, and future intents that reference
|
|
403
|
+
its id or chain), so a done intent stays fully readable forever. Intent 93 states this rule;
|
|
404
|
+
intent 112 builds the gate that enforces it.
|
|
405
|
+
|
|
406
|
+
Fail-safe lock doctrine (the contract intent 111 implements): the lock system never traps a
|
|
407
|
+
session or burns credits. When a gate cannot verify lock integrity it fails open, degrading
|
|
408
|
+
to advisory (warn) rather than hard-blocking. Repair is orchestrator-driven: on a lock-issue
|
|
409
|
+
signal the orchestrator inspects and repairs the lock automatically, and the human
|
|
410
|
+
`plastic-lock` command is a fallback path, not the trigger. Intent 93 states this doctrine;
|
|
411
|
+
intent 111 builds the fail-open behavior, the lock-liveness surface, the lock-issue message,
|
|
412
|
+
and the auto-repair.
|
|
413
|
+
|
|
414
|
+
Scope split. Intent 93 ships doctrine plus the low-risk reconciliation that needs no new
|
|
415
|
+
lock: the canonical done-marker and three-signal reconciliation, the mandatory `outcome.md`
|
|
416
|
+
plus `disposition` header at both terminals, the End tail with the reindex moved last, the
|
|
417
|
+
`done_signals` doctor check (three-signal agreement plus stalled-completion detection), and
|
|
418
|
+
the lock-bounded post-done window with its keep-guard test. Intent 111 owns the lock
|
|
419
|
+
liveness surface, the lock-issue message, orchestrator auto-repair, and the fail-open
|
|
420
|
+
behavior itself. Intent 112 owns the maintenance lock and the immutability gate (it inherits
|
|
421
|
+
fail-open from 111). Intent 4a1b1 owns deep agent stuck-detection and is not superseded.
|
|
422
|
+
|
|
353
423
|
## Deprecation Process
|
|
354
424
|
|
|
355
425
|
Deprecations live in `deprecations.yml` and are shown at SessionStart. While Plastic is
|
|
@@ -32,7 +32,10 @@ You are the Plastic Intent Curator. Your role is to maintain the health and navi
|
|
|
32
32
|
2. Read `~/.plastic/INDEX.md` (or project INDEX.md) to understand current organization
|
|
33
33
|
3. Compare: are there intents not in any cluster? Missing from Active/Completed/Abandoned? Status mismatches?
|
|
34
34
|
4. Make targeted edits to INDEX.md and intent frontmatter/links
|
|
35
|
-
5. On a terminal-state transition
|
|
35
|
+
5. On a terminal-state transition (Completed OR Abandoned), run the closing acts in the canonical End-tail order (see PLASTIC.md `## Delivery Isolation and the Single-Owner Lock`):
|
|
36
|
+
a. Author a real `outcome.md` in the intent directory from `~/.plastic/templates/outcome.md`, with the frontmatter `disposition: delivered` for a completed intent or `disposition: abandoned` for an abandoned one. `outcome.md` is MANDATORY at every terminal, delivered and abandoned alike: on abandon it records the abandonment reason and replaces the scaffolded placeholder sentinel (never leave `outcome.md` a placeholder at a terminal).
|
|
37
|
+
b. Stamp the terminal savepoint bookend (intent 81) recording the disposition. Idempotent.
|
|
38
|
+
c. Refresh the QMD index for that store LAST, after the terminal move, outcome.md, and savepoint, so the new outcome (or abandonment rationale) is searchable. This is mandatory on any terminal-state move and a no-op when QMD is absent, and it runs in the background so it never blocks: `ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root> --async`
|
|
36
39
|
6. Structural maintenance is move-and-record: remove the misplaced section, file, or ref from its artifact, then create or append `revisions.md` in that intent directory (copy the FORM from `~/.plastic/templates/revisions.md`). One entry per relocated item, newest at the bottom: a `## Revision vN - YYYY-MM-DD-HH:MM` header, a one-sentence `Why` ending with `[rule: <tag>]`, `Prior location`, and either `Content held` (verbatim) or a one-line `Change` for a frontmatter edit. For a stray file, embed its full content and delete the original. The violation-tag catalog is canonical in PLASTIC.md.
|
|
37
40
|
7. Report what you changed
|
|
38
41
|
|
package/hooks/code-gate
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
INPUT=$(cat)
|
|
3
|
-
FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
|
|
3
|
+
FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
|
|
4
|
+
data = JSON.parse(STDIN.read)
|
|
5
|
+
ti = data["tool_input"] || data["tool_params"] || {}
|
|
6
|
+
path = ti["file_path"] || ti["notebook_path"] || ti["relative_path"] || ""
|
|
7
|
+
if !path.empty? && !path.start_with?("/")
|
|
8
|
+
root = ti["project_root"] || data["cwd"] || ""
|
|
9
|
+
path = File.join(root, path) unless root.empty?
|
|
10
|
+
end
|
|
11
|
+
puts path
|
|
12
|
+
' 2>/dev/null)
|
|
4
13
|
|
|
5
14
|
if [ -z "$FILE_PATH" ]; then
|
|
6
15
|
exit 0
|
package/hooks/hooks.json
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
],
|
|
20
20
|
"PreCompact": [
|
|
21
21
|
{
|
|
22
|
-
"matcher": "
|
|
22
|
+
"matcher": "",
|
|
23
23
|
"hooks": [
|
|
24
24
|
{
|
|
25
25
|
"type": "command",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
],
|
|
32
32
|
"PreToolUse": [
|
|
33
33
|
{
|
|
34
|
-
"matcher": "Write|Edit|NotebookEdit",
|
|
34
|
+
"matcher": "Write|Edit|NotebookEdit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
|
|
35
35
|
"hooks": [
|
|
36
36
|
{
|
|
37
37
|
"type": "command",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
]
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
|
-
"matcher": "Write",
|
|
59
|
+
"matcher": "Write|Edit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
|
|
60
60
|
"hooks": [
|
|
61
61
|
{
|
|
62
62
|
"type": "command",
|
package/hooks/lock-gate
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
INPUT=$(cat)
|
|
3
|
-
FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
|
|
3
|
+
FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
|
|
4
|
+
data = JSON.parse(STDIN.read)
|
|
5
|
+
ti = data["tool_input"] || data["tool_params"] || {}
|
|
6
|
+
path = ti["file_path"] || ti["notebook_path"] || ti["relative_path"] || ""
|
|
7
|
+
if !path.empty? && !path.start_with?("/")
|
|
8
|
+
root = ti["project_root"] || data["cwd"] || ""
|
|
9
|
+
path = File.join(root, path) unless root.empty?
|
|
10
|
+
end
|
|
11
|
+
puts path
|
|
12
|
+
' 2>/dev/null)
|
|
4
13
|
|
|
5
14
|
if [ -z "$FILE_PATH" ]; then
|
|
6
15
|
exit 0
|
package/package.json
CHANGED
package/scripts/doctor.rb
CHANGED
|
@@ -20,6 +20,9 @@ require_relative "lib/intent_validator"
|
|
|
20
20
|
require_relative "lib/graph_rebuild"
|
|
21
21
|
require_relative "lib/links_projection"
|
|
22
22
|
require_relative "lib/links_section"
|
|
23
|
+
require_relative "lib/hook_registry"
|
|
24
|
+
require_relative "lib/lock"
|
|
25
|
+
require_relative "lib/bridge"
|
|
23
26
|
|
|
24
27
|
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
25
28
|
# run it hermetically (no eval, no global-constant rewriting).
|
|
@@ -504,6 +507,184 @@ class Doctor
|
|
|
504
507
|
checks
|
|
505
508
|
end
|
|
506
509
|
|
|
510
|
+
# --- Check: the three done-signals agree + stalled completion (intent 93) ---
|
|
511
|
+
#
|
|
512
|
+
# "Done" is one law with three signals that MUST agree: INDEX
|
|
513
|
+
# `## Completed`/`## Abandoned` is the single canonical terminal marker;
|
|
514
|
+
# `outcome.md` is the "deliverable exists" signal (real, non-placeholder, with a
|
|
515
|
+
# `disposition:` header); the savepoint `Done` line is the audit echo. INDEX
|
|
516
|
+
# wins on conflict. This check flags any disagreement, and separately surfaces a
|
|
517
|
+
# "stalled completion": an intent that is terminal in INDEX but whose End tail
|
|
518
|
+
# never released its `delivery.lock` (the post-done window never closed). Read
|
|
519
|
+
# only, dependency-light: it uses INDEX parsing, file presence, the placeholder
|
|
520
|
+
# sentinel, and `Lock.fresh?` — no new lock, no 111 lock-liveness surface.
|
|
521
|
+
|
|
522
|
+
# For one store's INDEX.md, map each referenced intent directory name to the
|
|
523
|
+
# `## ...` section(s) it appears under: { "<id>--<slug>" => ["Active", ...] }.
|
|
524
|
+
def index_sections_by_dir(index_path)
|
|
525
|
+
sections = Hash.new { |h, k| h[k] = [] }
|
|
526
|
+
return sections unless File.exist?(index_path)
|
|
527
|
+
|
|
528
|
+
current = nil
|
|
529
|
+
File.foreach(index_path) do |line|
|
|
530
|
+
if (m = line.match(/^##\s+(.+?)\s*$/))
|
|
531
|
+
current = m[1]
|
|
532
|
+
next
|
|
533
|
+
end
|
|
534
|
+
next unless current
|
|
535
|
+
|
|
536
|
+
line.scan(%r{store/([\w][\w.-]*?)(?:/|\))}) do |(dirname)|
|
|
537
|
+
sections[dirname] << current unless sections[dirname].include?(current)
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
sections
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Stores to reconcile: the global store plus each project store, filtered by
|
|
544
|
+
# `scopes` (nil = all) exactly like check_conventions.
|
|
545
|
+
def done_signal_stores(scopes)
|
|
546
|
+
stores = []
|
|
547
|
+
|
|
548
|
+
if scopes.nil? || scopes.include?("global")
|
|
549
|
+
stores << {
|
|
550
|
+
scope: "global",
|
|
551
|
+
index: File.join(plastic_home, "INDEX.md"),
|
|
552
|
+
store_dir: File.join(plastic_home, "store"),
|
|
553
|
+
}
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
projects_root = File.join(plastic_home, "projects")
|
|
557
|
+
if File.directory?(projects_root)
|
|
558
|
+
Dir.children(projects_root).sort.each do |project|
|
|
559
|
+
scope = "project:#{project}"
|
|
560
|
+
next unless scopes.nil? || scopes.include?(scope)
|
|
561
|
+
|
|
562
|
+
store_dir = File.join(projects_root, project, "store")
|
|
563
|
+
next unless File.directory?(store_dir)
|
|
564
|
+
|
|
565
|
+
stores << {
|
|
566
|
+
scope: scope,
|
|
567
|
+
index: File.join(projects_root, project, "INDEX.md"),
|
|
568
|
+
store_dir: store_dir,
|
|
569
|
+
}
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
stores
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def check_done_signals(scopes: nil)
|
|
577
|
+
conflicts = [] # canonical INDEX-wins disagreement (fail)
|
|
578
|
+
gaps = [] # terminal completeness gaps on immutable/legacy history (warn)
|
|
579
|
+
stalled = [] # terminal but End tail unfinished (warn)
|
|
580
|
+
|
|
581
|
+
done_signal_stores(scopes).each do |store|
|
|
582
|
+
index_sections_by_dir(store[:index]).each do |dirname, in_sections|
|
|
583
|
+
dir = File.join(store[:store_dir], dirname)
|
|
584
|
+
next unless File.directory?(dir)
|
|
585
|
+
|
|
586
|
+
terminal = (in_sections & ["Completed", "Abandoned"]).any?
|
|
587
|
+
active = in_sections.include?("Active") && !terminal
|
|
588
|
+
outcome = File.join(dir, "outcome.md")
|
|
589
|
+
outcome_real = Bridge.stage_file_present?(outcome)
|
|
590
|
+
label = "#{store[:scope]} store/#{dirname}"
|
|
591
|
+
|
|
592
|
+
# HARD conflict: the deliverable exists but INDEX still says Active. This
|
|
593
|
+
# is the one true INDEX-wins disagreement, so it stays a fail.
|
|
594
|
+
if active && outcome_real
|
|
595
|
+
conflicts << "#{label}: outcome.md is real but the intent is still under ## Active " \
|
|
596
|
+
"(INDEX is canonical — move it to its terminal section or revert outcome.md)"
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Completeness gap (warn, not fail): a terminal intent whose outcome.md is
|
|
600
|
+
# missing/placeholder. Legacy terminal intents predate this convention and
|
|
601
|
+
# are immutable, so this is advisory, never breakage.
|
|
602
|
+
if terminal && !outcome_real
|
|
603
|
+
state = File.exist?(outcome) ? "still a placeholder" : "missing"
|
|
604
|
+
gaps << "#{label}: terminal in INDEX but outcome.md is #{state} " \
|
|
605
|
+
"(author outcome.md with the disposition header)"
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
next unless terminal
|
|
609
|
+
|
|
610
|
+
# Audit echo (weakest signal, D1): the savepoint should carry a
|
|
611
|
+
# `Done delivered|abandoned` line. Missing on legacy history -> advisory gap.
|
|
612
|
+
savepoint = File.join(dir, "savepoint.md")
|
|
613
|
+
if File.exist?(savepoint) && File.read(savepoint) !~ /\bDone\b.*\b(delivered|abandoned)\b/
|
|
614
|
+
gaps << "#{label}: terminal in INDEX but savepoint.md has no " \
|
|
615
|
+
"`Done delivered|abandoned` line (audit echo missing)"
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Stalled completion: the End tail never released the delivery lock, so the
|
|
619
|
+
# post-done window `[INDEX terminal -> Lock.release]` never closed.
|
|
620
|
+
if File.exist?(Lock.path(dir))
|
|
621
|
+
note = Lock.fresh?(dir) ? "delivery.lock still present (post-done window not closed)"
|
|
622
|
+
: "delivery.lock is present and STALE"
|
|
623
|
+
stalled << "#{label}: #{note} — the End tail did not finish"
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
checks = []
|
|
629
|
+
|
|
630
|
+
# signals_agree: only the canonical INDEX-wins conflict is a hard fail, so
|
|
631
|
+
# `doctor` never goes red on immutable legacy terminal intents.
|
|
632
|
+
if conflicts.empty?
|
|
633
|
+
checks << check(
|
|
634
|
+
category: "done_signals", name: "signals_agree", status: "pass",
|
|
635
|
+
message: "No done-signal conflicts (no intent has a real outcome.md while still Active)"
|
|
636
|
+
)
|
|
637
|
+
else
|
|
638
|
+
checks << check(
|
|
639
|
+
category: "done_signals", name: "signals_agree", status: "fail",
|
|
640
|
+
message: "#{conflicts.size} done-signal conflict#{conflicts.size == 1 ? "" : "s"} " \
|
|
641
|
+
"(INDEX ## Completed/## Abandoned is canonical; a real outcome.md must not stay Active)",
|
|
642
|
+
details: conflicts, fixable: true,
|
|
643
|
+
fix_hint: "Reconcile to INDEX (canonical): move the intent to its terminal section, or revert " \
|
|
644
|
+
"outcome.md to a placeholder. Deliverable-exists but still-Active is the one done-signal " \
|
|
645
|
+
"state that must never persist."
|
|
646
|
+
)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# signals_complete: terminal completeness gaps are advisory (warn), because
|
|
650
|
+
# completed intents are immutable and legacy history predates this convention.
|
|
651
|
+
if gaps.empty?
|
|
652
|
+
checks << check(
|
|
653
|
+
category: "done_signals", name: "signals_complete", status: "pass",
|
|
654
|
+
message: "Every terminal intent carries a real outcome.md and a Done savepoint echo"
|
|
655
|
+
)
|
|
656
|
+
else
|
|
657
|
+
checks << check(
|
|
658
|
+
category: "done_signals", name: "signals_complete", status: "warn",
|
|
659
|
+
message: "#{gaps.size} terminal completeness gap#{gaps.size == 1 ? "" : "s"} " \
|
|
660
|
+
"(outcome.md or the savepoint Done echo is missing; advisory on immutable history)",
|
|
661
|
+
details: gaps, fixable: true,
|
|
662
|
+
fix_hint: "For live terminals, author a real outcome.md with the `disposition:` header via the " \
|
|
663
|
+
"completion/abandon path (plastic-auto or plastic-intent-curator) and stamp the terminal " \
|
|
664
|
+
"savepoint. Legacy terminal intents are immutable, so pre-convention gaps stay advisory."
|
|
665
|
+
)
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
if stalled.empty?
|
|
669
|
+
checks << check(
|
|
670
|
+
category: "done_signals", name: "stalled_completion", status: "pass",
|
|
671
|
+
message: "No stalled completions (every terminal intent released its delivery lock)"
|
|
672
|
+
)
|
|
673
|
+
else
|
|
674
|
+
checks << check(
|
|
675
|
+
category: "done_signals", name: "stalled_completion", status: "warn",
|
|
676
|
+
message: "#{stalled.size} stalled completion#{stalled.size == 1 ? "" : "s"} " \
|
|
677
|
+
"(terminal in INDEX but the End tail did not finish)",
|
|
678
|
+
details: stalled, fixable: true,
|
|
679
|
+
fix_hint: "Finish the End tail via stale-lock reclaim: `plastic-lock reclaim`, then complete the " \
|
|
680
|
+
"tail (Worktree.release -> Lock.release -> purge -> QMD reindex last). This FINISHES a " \
|
|
681
|
+
"completion; it is NOT a reactivation of a done intent."
|
|
682
|
+
)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
checks
|
|
686
|
+
end
|
|
687
|
+
|
|
507
688
|
# Build the cross-store node maps (basename + label per store) + relocation map
|
|
508
689
|
# from ALL stores, then for every intent compute its canonical `## Links`
|
|
509
690
|
# projection and flag any whose ACTUAL `## Links` section differs (membership or
|
|
@@ -827,6 +1008,45 @@ class Doctor
|
|
|
827
1008
|
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
828
1009
|
)
|
|
829
1010
|
end
|
|
1011
|
+
|
|
1012
|
+
# hooks_match_registry (intent 108, D7): the live settings must carry
|
|
1013
|
+
# EXACTLY the registrations HookRegistry defines; any drift (a missing
|
|
1014
|
+
# gate, a stray plastic hook, a stale matcher) is how bash-gate shipped
|
|
1015
|
+
# dead once already.
|
|
1016
|
+
expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
|
|
1017
|
+
diffs = []
|
|
1018
|
+
expected.each do |event, group|
|
|
1019
|
+
groups = group.is_a?(Array) ? group : [group]
|
|
1020
|
+
live = settings.dig("hooks", event) || []
|
|
1021
|
+
groups.each do |g|
|
|
1022
|
+
match = live.find { |h| h.is_a?(Hash) && h["matcher"] == g["matcher"] }
|
|
1023
|
+
wanted = g["hooks"].map { |h| h["command"] }
|
|
1024
|
+
got = match ? Array(match["hooks"]).map { |h| h["command"] } : []
|
|
1025
|
+
missing = wanted - got
|
|
1026
|
+
diffs << "#{event}[#{g['matcher']}] missing: #{missing.join(', ')}" unless missing.empty?
|
|
1027
|
+
end
|
|
1028
|
+
end
|
|
1029
|
+
live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
|
|
1030
|
+
Array(groups).flat_map do |g|
|
|
1031
|
+
next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
|
|
1032
|
+
g["hooks"].map { |h| h["command"].to_s }.select { |c| c.include?("plastic-") }
|
|
1033
|
+
.map { |c| "#{event}: #{c}" }
|
|
1034
|
+
end
|
|
1035
|
+
end
|
|
1036
|
+
expected_cmds = expected.values.flat_map { |g| g.is_a?(Array) ? g : [g] }
|
|
1037
|
+
.flat_map { |g| g["hooks"].map { |h| h["command"] } }
|
|
1038
|
+
strays = live_plastic.reject { |lp| expected_cmds.any? { |c| lp.end_with?(c) } }
|
|
1039
|
+
diffs.concat(strays.map { |s| "stray: #{s}" })
|
|
1040
|
+
|
|
1041
|
+
checks << if diffs.empty?
|
|
1042
|
+
check(category: "agent_registration", name: "hooks_match_registry",
|
|
1043
|
+
status: "pass", message: "settings.json hooks match HookRegistry")
|
|
1044
|
+
else
|
|
1045
|
+
check(category: "agent_registration", name: "hooks_match_registry",
|
|
1046
|
+
status: "fail", message: "#{diffs.size} hook registration(s) diverge from HookRegistry",
|
|
1047
|
+
details: diffs, fixable: true,
|
|
1048
|
+
fix_hint: "Re-run the installer merge: npx @zalom/plastic update (or ruby ~/.plastic/scripts/install.rb)")
|
|
1049
|
+
end
|
|
830
1050
|
end
|
|
831
1051
|
|
|
832
1052
|
# skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
|
|
@@ -1417,6 +1637,7 @@ class Doctor
|
|
|
1417
1637
|
all_checks += check_project_stores
|
|
1418
1638
|
all_checks += check_deprecations
|
|
1419
1639
|
all_checks += check_qmd
|
|
1640
|
+
all_checks += check_done_signals
|
|
1420
1641
|
|
|
1421
1642
|
summarize(all_checks, agent_key)
|
|
1422
1643
|
end
|
|
@@ -1443,9 +1664,9 @@ class Doctor
|
|
|
1443
1664
|
all_checks =
|
|
1444
1665
|
case store
|
|
1445
1666
|
when :all
|
|
1446
|
-
check_global_store + check_project_stores + check_conventions
|
|
1667
|
+
check_global_store + check_project_stores + check_conventions + check_done_signals
|
|
1447
1668
|
when :global
|
|
1448
|
-
check_global_store + check_conventions(scopes: ["global"])
|
|
1669
|
+
check_global_store + check_conventions(scopes: ["global"]) + check_done_signals(scopes: ["global"])
|
|
1449
1670
|
else
|
|
1450
1671
|
all_checks_for_project_slug(store)
|
|
1451
1672
|
end
|
|
@@ -1468,7 +1689,8 @@ class Doctor
|
|
|
1468
1689
|
end
|
|
1469
1690
|
|
|
1470
1691
|
check_project_store(slug, projects[slug]) +
|
|
1471
|
-
check_conventions(scopes: ["project:#{slug}"])
|
|
1692
|
+
check_conventions(scopes: ["project:#{slug}"]) +
|
|
1693
|
+
check_done_signals(scopes: ["project:#{slug}"])
|
|
1472
1694
|
end
|
|
1473
1695
|
|
|
1474
1696
|
# Roll a list of checks up into the standard result envelope.
|
package/scripts/hook-bash-gate
CHANGED
|
@@ -28,13 +28,31 @@ exit 0 if command.nil? || command.to_s.strip.empty?
|
|
|
28
28
|
cwd = payload["cwd"]
|
|
29
29
|
cwd = Dir.pwd if cwd.nil? || cwd.to_s.empty?
|
|
30
30
|
|
|
31
|
-
# --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
|
|
32
31
|
session = payload["session_id"]
|
|
33
32
|
session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.to_s.empty?
|
|
33
|
+
|
|
34
|
+
# Auditable escape (intent 108, D7): a trailing `# plastic-ok` allows the
|
|
35
|
+
# command and logs it, so sanctioned writes are visible, not silent.
|
|
36
|
+
if Bridge.bash_escape?(command)
|
|
37
|
+
begin
|
|
38
|
+
require "fileutils"
|
|
39
|
+
log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(log))
|
|
41
|
+
File.open(log, "a") do |io|
|
|
42
|
+
io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{command.gsub(/\s+/, ' ').strip}")
|
|
43
|
+
end
|
|
44
|
+
rescue StandardError
|
|
45
|
+
# the escape still applies; logging is best-effort
|
|
46
|
+
end
|
|
47
|
+
exit 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
|
|
51
|
+
# A nil bridge does NOT short-circuit (intent 108): the lock gate decides from
|
|
52
|
+
# the durable delivery.lock file, so it must run even without a bridge cache.
|
|
34
53
|
bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
|
|
35
|
-
exit 0 unless bridge_data
|
|
36
54
|
|
|
37
|
-
reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd)
|
|
55
|
+
reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd, session: session)
|
|
38
56
|
exit 0 unless reason
|
|
39
57
|
|
|
40
58
|
$stderr.puts "PLASTIC GATE — #{reason}"
|
package/scripts/hook-create-gate
CHANGED
|
@@ -19,8 +19,13 @@
|
|
|
19
19
|
# Reads the Claude Code PreToolUse payload as JSON on STDIN:
|
|
20
20
|
# { "tool_input": { "file_path": "...", "content": "..." } }
|
|
21
21
|
# Empty / unparseable / non-matching path => exit 0 (cannot judge, allow).
|
|
22
|
-
#
|
|
23
|
-
#
|
|
22
|
+
# Three payload shapes on a matching path (intent 108, D7):
|
|
23
|
+
# Write (content): validate the proposed content.
|
|
24
|
+
# Edit (old_string): simulate the replacement on the on-disk file and
|
|
25
|
+
# validate the RESULT; a missing file blocks.
|
|
26
|
+
# Pathless MCP mutation: validate the CURRENT on-disk file (the PostToolUse
|
|
27
|
+
# gate-check backstop validates the result); a
|
|
28
|
+
# missing file blocks (fail-safe).
|
|
24
29
|
|
|
25
30
|
require "json"
|
|
26
31
|
require_relative "lib/intent_validator"
|
|
@@ -43,14 +48,45 @@ is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
|
|
|
43
48
|
File.basename(abs) == "#{File.basename(dir)}.md"
|
|
44
49
|
exit 0 unless is_intent_file
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
input = payload["tool_input"] || payload["tool_params"] || {}
|
|
52
|
+
content = input["content"]
|
|
53
|
+
old_string = input["old_string"]
|
|
54
|
+
|
|
55
|
+
def block!(lines)
|
|
56
|
+
Array(lines).each { |l| $stderr.puts l }
|
|
50
57
|
exit 2
|
|
51
58
|
end
|
|
52
59
|
|
|
53
|
-
result =
|
|
60
|
+
result =
|
|
61
|
+
if !content.nil?
|
|
62
|
+
# Write: validate the proposed content (intent 60b, unchanged).
|
|
63
|
+
IntentValidator.validate_content(content)
|
|
64
|
+
elsif !old_string.nil?
|
|
65
|
+
# Edit (intent 108, D7): simulate the replacement on the on-disk file and
|
|
66
|
+
# validate the RESULT, so an Edit can no longer sneak an intent file past
|
|
67
|
+
# the validator.
|
|
68
|
+
unless File.exist?(abs)
|
|
69
|
+
block!("PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
|
|
70
|
+
"create intents via new-intent / plastic-creating-intent.")
|
|
71
|
+
end
|
|
72
|
+
current = File.read(abs)
|
|
73
|
+
unless current.include?(old_string)
|
|
74
|
+
exit 0 # the Edit tool itself will fail; nothing lands, nothing to judge
|
|
75
|
+
end
|
|
76
|
+
simulated = input["replace_all"] ? current.gsub(old_string, input["new_string"].to_s)
|
|
77
|
+
: current.sub(old_string, input["new_string"].to_s)
|
|
78
|
+
IntentValidator.validate_content(simulated)
|
|
79
|
+
else
|
|
80
|
+
# Pathless mutation (MCP symbolic edits): the proposal is not visible at
|
|
81
|
+
# PreToolUse. Judge the CURRENT file: valid -> allow (the PostToolUse
|
|
82
|
+
# gate-check backstop validates the result), invalid/missing -> block.
|
|
83
|
+
unless File.exist?(abs)
|
|
84
|
+
block!("PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed " \
|
|
85
|
+
"content and no file exists; refusing to allow an unvalidated intent write.")
|
|
86
|
+
end
|
|
87
|
+
IntentValidator.validate_content(File.read(abs))
|
|
88
|
+
end
|
|
89
|
+
|
|
54
90
|
exit 0 if result[:ok]
|
|
55
91
|
|
|
56
92
|
$stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
|
package/scripts/hook-gate-check
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
require "json"
|
|
10
10
|
require_relative "lib/bridge"
|
|
11
11
|
require_relative "lib/intent_validator"
|
|
12
|
+
require_relative "lib/lock"
|
|
12
13
|
|
|
13
14
|
file_path = ARGV[0]
|
|
14
15
|
exit 0 unless file_path && !file_path.empty?
|
|
@@ -72,6 +73,15 @@ intent_dir = "#{store}/#{dir}"
|
|
|
72
73
|
# Normalize both paths for comparison
|
|
73
74
|
bridge_intent_dir_abs = File.expand_path(intent_dir)
|
|
74
75
|
|
|
76
|
+
# Lease heartbeat (intent 108, D1): every write by the session that owns (or
|
|
77
|
+
# delegates under) the lock refreshes the delivery.lock mtime. Best-effort:
|
|
78
|
+
# a heartbeat failure must never break the gate hook.
|
|
79
|
+
begin
|
|
80
|
+
Lock.heartbeat(bridge_intent_dir_abs, session: session)
|
|
81
|
+
rescue StandardError
|
|
82
|
+
# ignore
|
|
83
|
+
end
|
|
84
|
+
|
|
75
85
|
# Not inside intent dir = not our business
|
|
76
86
|
exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
|
|
77
87
|
|