@zalom/plastic 1.12.0 → 1.14.0

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.
@@ -15,7 +15,7 @@
15
15
  # maintenance-run --tool rebuild-graph [--plastic-home PATH] [--apply]
16
16
  # maintenance-run --tool restore-intent-v1 <id> --at <ref> [--plastic-home PATH] [--apply] [--skip-links]
17
17
  # maintenance-run --tool rebuild-savepoint --intent <id> [--store <key>] [--plastic-home PATH] [--apply]
18
- # maintenance-run --tool register-exclusions [--rule <name>] [--store <key>] [--plastic-home PATH] [--apply]
18
+ # maintenance-run --tool register-exclusions [--prune] [--rule <name>] [--store <key>] [--plastic-home PATH] [--apply]
19
19
  #
20
20
  # project-links here is ALWAYS single-intent: --intent is required. A store-wide
21
21
  # project-links sweep is the rare, owner-approved batch exception (D2) and is run directly
@@ -77,7 +77,7 @@ end
77
77
 
78
78
  def parse_argv(argv)
79
79
  opts = { tool: nil, intent: nil, store: nil, plastic_home: DEFAULT_HOME, apply: false,
80
- at: nil, skip_links: false, id: nil, rule: nil }
80
+ at: nil, skip_links: false, id: nil, rule: nil, prune: false }
81
81
  i = 0
82
82
  while i < argv.length
83
83
  case argv[i]
@@ -89,6 +89,7 @@ def parse_argv(argv)
89
89
  when "--at" then opts[:at] = argv[i += 1]
90
90
  when "--skip-links" then opts[:skip_links] = true
91
91
  when "--rule" then opts[:rule] = argv[i += 1]
92
+ when "--prune" then opts[:prune] = true
92
93
  else
93
94
  opts[:id] ||= argv[i] # positional id, restore-intent-v1 only
94
95
  end
@@ -311,12 +312,53 @@ end
311
312
  # structurally edit an intent's OWN files) does not apply, and writing one would mean editing
312
313
  # every touched Completed intent directory - forbidden, completed intents are immutable. The
313
314
  # scoped commit plus the diffable exclusion file itself are the receipt.
314
- def run_register_exclusions(home, rule, store, apply)
315
+ #
316
+ # `prune:` (intent 280) reverses the direction: instead of adding newly-violating ids, it
317
+ # removes rows that `DoctorExclusions.dead_rows` reports as suppressing nothing, through the
318
+ # SAME walk, the SAME comment-preserving writer, and the SAME dry-run/--apply gate. `known_ids`
319
+ # (post-review fix) is resolved against a direct scan of the store's own directory listing, never
320
+ # against which ids the INDEX walk happened to visit - an id can have a real directory without
321
+ # being listed in INDEX at all (a de-indexed "ghost"), and walk membership alone misclassified
322
+ # that as :no_intent (deleted) even though the directory plainly still exists. `evaluated_ids` is
323
+ # the narrower set the walk DOES visit; an id with a real directory the walk never evaluated
324
+ # carries no evidence either way, so `dead_rows` leaves it out of its result entirely - never
325
+ # called dead by the reporter, so `prune` never sees it as a pruning candidate in the first
326
+ # place.
327
+ #
328
+ # The walk here only ever evaluates `savepoint_operational`'s own finding bucket
329
+ # (`findings[:operational_gap]`), regardless of `--rule`: `run_register_exclusions` refuses
330
+ # `--prune --rule <other>` outright (a second review fix) rather than silently computing
331
+ # `found_ids` from an unrelated check and misreporting every row under `<other>` dead.
332
+ #
333
+ # Three classes of row are additionally held harmless before anything is written
334
+ # (`protected_ids`), belt-and-suspenders on top of the `evaluated_ids` gate: an id whose dir was
335
+ # skipped for a fresh delivery lock (D6 - the lock skip runs AFTER the id already lands in
336
+ # `evaluated_ids`, but BEFORE it can ever reach `found_ids`, since findings are never computed
337
+ # for it), an id whose intent has not reached a terminal state yet (D6a - savepoint_operational
338
+ # only fires on a terminal intent, so the row has nothing to suppress YET), and an id with a real
339
+ # directory that is not in `evaluated_ids` at all (on disk, unindexed - this last class never
340
+ # actually reaches `held` in practice, since `dead_rows`'s own gate already excludes it; the
341
+ # protection stays explicit anyway rather than relying solely on that gate). A rule left with
342
+ # zero ids after pruning is dropped from the hash entirely (D7): `render_exclusions_file` would
343
+ # otherwise write a bare `rule_name` line that `DoctorExclusions.parse` rejects as "lists no
344
+ # intent ids".
345
+ def run_register_exclusions(home, rule, store, apply, prune = false)
315
346
  rule ||= "savepoint_operational"
316
347
  unless RuleCatalog.excludable_check?(rule)
317
348
  abort_loud("--rule #{rule.inspect} is not excludable (expected one of: " \
318
349
  "#{RuleCatalog::EXCLUDABLE_CHECKS.keys.join(", ")})")
319
350
  end
351
+ # Review fix: the walk below only ever evaluates savepoint_operational's own finding bucket
352
+ # (`findings[:operational_gap]`), regardless of --rule. Passing --prune --rule <other> would
353
+ # compute `found_ids` from an entirely unrelated check and subtract it against <other>'s
354
+ # registered ids, misreporting every one of them dead. Unreachable in v1 (EXCLUDABLE_CHECKS
355
+ # carries exactly one key) but one catalog entry away, so refuse it explicitly rather than
356
+ # silently mis-pruning the day a second excludable check exists.
357
+ if prune && rule != "savepoint_operational"
358
+ abort_loud("--prune evaluates only savepoint_operational; #{rule.inspect} rows are left " \
359
+ "untouched (the walk this tool runs only ever checks that one rule's finding " \
360
+ "bucket, regardless of --rule)")
361
+ end
320
362
 
321
363
  doctor = Doctor.new(plastic_home: home)
322
364
  stores = doctor.done_signal_stores(store ? [store] : nil)
@@ -340,15 +382,45 @@ def run_register_exclusions(home, rule, store, apply)
340
382
  existing_text = File.exist?(existing[:path]) ? File.read(existing[:path]).scrub : nil
341
383
 
342
384
  found_ids = []
385
+ protected_ids = []
386
+
387
+ # `known_ids` (post-review fix): every id with a REAL DIRECTORY in this store, scanned
388
+ # directly from disk - not from the INDEX walk below. Mirrors doctor.rb's own fix: an id can
389
+ # have a directory on disk without being listed in INDEX (a de-indexed "ghost"), and the walk
390
+ # alone would never visit it, misclassifying that ghost as :no_intent (deleted) even though
391
+ # it plainly still exists.
392
+ # Shares doctor.store_intent_dirs (doctor.rb:159, intent 189's store-discovery helper)
393
+ # rather than reimplementing the same directory scan (review fix): one predicate for "what
394
+ # is an intent directory in this store", never two that could drift apart.
395
+ known_ids = if File.directory?(s[:store_dir])
396
+ doctor.store_intent_dirs(s[:store_dir]).map { |e| e.split("--", 2).first }
397
+ else
398
+ []
399
+ end
400
+ # `evaluated_ids`: the narrower set the walk below actually judges (INDEX-listed and on disk).
401
+ evaluated_ids = []
402
+
343
403
  doctor.index_sections_by_dir(s[:index]).each do |dirname, in_sections|
344
404
  dir = File.join(s[:store_dir], dirname)
345
405
  next unless File.directory?(dir)
346
406
 
407
+ walked_id = dirname.split("--", 2).first
408
+ evaluated_ids << walked_id
409
+ # D6a: savepoint_operational only fires on a terminal intent, so a row naming a live
410
+ # non-terminal intent has nothing to suppress YET. Doctor reports it; prune leaves it.
411
+ protected_ids << walked_id unless (in_sections & ["Completed", "Abandoned"]).any?
412
+
347
413
  terminal = (in_sections & ["Completed", "Abandoned"]).any?
348
414
  next unless terminal
349
415
 
350
416
  if Lock.fresh?(dir)
351
417
  skip_lines << "#{s[:scope]}: #{dirname} skipped (fresh delivery lock)"
418
+ # Prune direction (intent 280 D6, post-review fix): the lock skip runs AFTER
419
+ # evaluated_ids already recorded this id (just above) but BEFORE findings are computed,
420
+ # so it never reaches `consumed` - dead_rows would read it as :no_finding (evaluated,
421
+ # nothing consumed) purely because we never checked. Hold it harmless via protected_ids
422
+ # regardless of what dead_rows would report.
423
+ protected_ids << walked_id
352
424
  next
353
425
  end
354
426
 
@@ -357,36 +429,74 @@ def run_register_exclusions(home, rule, store, apply)
357
429
  dir, label: "#{s[:scope]} store/#{dirname}", scope: s[:scope], dirname: dirname,
358
430
  terminal: terminal, active: active, excluded_rules: []
359
431
  )
360
- found_ids << dirname.split("--", 2).first if findings[:operational_gap].any?
432
+ found_ids << walked_id if findings[:operational_gap].any?
361
433
  end
362
434
 
435
+ # Post-review fix: an id with a real directory that INDEX never lists at all is walked by
436
+ # neither branch above, so its terminal state is unknowable here. PURE DEFENSE-IN-DEPTH:
437
+ # `dead_rows`'s own `evaluated_ids` gate already excludes such an id from `dead` entirely (it
438
+ # is neither :no_finding nor :no_intent - no evidence either way), so `held` below never
439
+ # actually contains one and this concat's protection never needs to fire. Unlike D6 (fresh
440
+ # lock) and D6a (not yet terminal), which DO reach `held` and print "kept (protected, still
441
+ # live)", an unindexed id is never named in that skip output - it was never a live pruning
442
+ # candidate to begin with.
443
+ protected_ids.concat(known_ids - evaluated_ids)
444
+
363
445
  already = existing[:rules][rule] || []
364
- added = found_ids - already
365
- next if added.empty?
366
446
 
367
- merged_rules = existing[:rules].merge(rule => (already | found_ids).sort)
368
- plan[s] = { content: render_exclusions_file(merged_rules, existing_text: existing_text), added: added.sort }
447
+ if prune
448
+ # This tool calls done_signal_findings_for_dir with excluded_rules: [], so a registered id
449
+ # that still has a gap shows up in found_ids rather than in the :excluded bucket. found_ids
450
+ # IS the consumed set for this rule.
451
+ dead = DoctorExclusions.dead_rows(existing, consumed: { rule => found_ids }, known_ids: known_ids,
452
+ evaluated_ids: evaluated_ids)
453
+ .select { |row| row[:rule] == rule }
454
+ .map { |row| row[:id] }
455
+ held = dead & protected_ids # D6 (fresh lock) + D6a (not terminal) + on-disk-but-unindexed
456
+ held.each { |id| skip_lines << "#{s[:scope]}: #{id} kept (protected, still live)" }
457
+ dead -= held
458
+ next if dead.empty?
459
+
460
+ remaining = already - dead
461
+ merged_rules = existing[:rules].merge(rule => remaining.sort)
462
+ merged_rules.delete(rule) if remaining.empty? # D7: a bare rule line fails to reload
463
+ plan[s] = { content: render_exclusions_file(merged_rules, existing_text: existing_text),
464
+ removed: dead.sort }
465
+ else
466
+ added = found_ids - already
467
+ next if added.empty?
468
+
469
+ merged_rules = existing[:rules].merge(rule => (already | found_ids).sort)
470
+ plan[s] = { content: render_exclusions_file(merged_rules, existing_text: existing_text),
471
+ added: added.sort }
472
+ end
369
473
  end
370
474
 
371
475
  puts skip_lines.join("\n") unless skip_lines.empty?
372
476
 
373
477
  if plan.empty?
374
- puts "maintenance-run: no new #{rule} violations to register."
478
+ puts(prune ? "maintenance-run: no dead #{rule} exclusion rows to prune."
479
+ : "maintenance-run: no new #{rule} violations to register.")
375
480
  exit 0
376
481
  end
377
482
 
378
483
  unless apply
379
484
  plan.each do |s, info|
380
- puts "maintenance-run: DRY RUN, #{s[:scope]} would register #{info[:added].size} " \
381
- "id(s) under #{rule}: #{info[:added].join(", ")}"
485
+ puts(if prune
486
+ "maintenance-run: DRY RUN, #{s[:scope]} would prune #{info[:removed].size} " \
487
+ "dead row(s) under #{rule}: #{info[:removed].join(", ")}"
488
+ else
489
+ "maintenance-run: DRY RUN, #{s[:scope]} would register #{info[:added].size} " \
490
+ "id(s) under #{rule}: #{info[:added].join(", ")}"
491
+ end)
382
492
  end
383
493
  exit 0
384
494
  end
385
495
 
386
496
  begin
387
497
  result = MaintenanceGit.run_scoped(
388
- repo_dir: home, branch_name: "maintenance/register-exclusions-#{stamp}",
389
- commit_message: "chore: maintenance - register doctor exclusions (#{rule})"
498
+ repo_dir: home, branch_name: "maintenance/#{prune ? "prune" : "register"}-exclusions-#{stamp}",
499
+ commit_message: "chore: maintenance - #{prune ? "prune dead" : "register"} doctor exclusions (#{rule})"
390
500
  ) do
391
501
  plan.each { |s, info| File.write(DoctorExclusions.path_for(s[:index]), info[:content]) }
392
502
  end
@@ -414,7 +524,7 @@ def main(argv)
414
524
  when "rebuild-savepoint"
415
525
  run_rebuild_savepoint(opts[:plastic_home], opts[:intent], opts[:store], opts[:apply])
416
526
  when "register-exclusions"
417
- run_register_exclusions(opts[:plastic_home], opts[:rule], opts[:store], opts[:apply])
527
+ run_register_exclusions(opts[:plastic_home], opts[:rule], opts[:store], opts[:apply], opts[:prune])
418
528
  else
419
529
  abort_loud("unknown --tool #{opts[:tool].inspect} (expected project-links|rebuild-graph|" \
420
530
  "restore-intent-v1|rebuild-savepoint|register-exclusions)")
@@ -31,8 +31,8 @@
31
31
  #
32
32
  # outcome is an end-of-Exec step. Run it once the diff and the test summary exist, right
33
33
  # before plastic-intent-ending writes the narrative. Writing outcome.md makes the intent
34
- # read as Done to the statusline and to any stage-derived display (Bridge.derive_stage
35
- # keys on outcome.md's presence). This does NOT purge a bridge:
34
+ # read as Done to any stage-derived display (Bridge.derive_stage keys on outcome.md's
35
+ # presence). This does NOT purge a bridge:
36
36
  # Bridge.purge_done_bridges keys on INDEX Active status plus lock presence, never on the
37
37
  # derived stage.
38
38
 
@@ -19,7 +19,7 @@ when the trigger in the second column applies to the work in front of you.
19
19
  | `references/knowledge-graph.md` | when creating, linking, curating, or indexing intents and you need the sources-vs-chain doctrine, the tiers of influence, the `## Links` projection, or branch-vs-root directory semantics |
20
20
  | `references/lifecycle-and-savepoints.md` | when running a lifecycle stage or a savepoint and you need the subagent report-home contract for how an insight reaches the intent |
21
21
  | `references/tiers-and-dispatch.md` | when sizing an intent, choosing agent models, routing to the advisor, or writing an auto-mode human report |
22
- | `references/gates-and-enforcement.md` | when a transition gate blocks you, or before using an audited escape, for the gate mechanics and the logging contract, or when naming, registering, or retiring a hook |
22
+ | `references/gates-and-enforcement.md` | when a transition gate blocks you, or before using an audited escape, for the gate mechanics and the logging contract, or when naming, registering, or retiring a hook or skill |
23
23
  | `references/locks-and-worktrees.md` | before taking or releasing a delivery lock, and when working with claims, worktrees, solo mode, or the station ledger |
24
24
  | `references/completion-and-done.md` | when ending an intent, for what "intent done" means and the End-stage tail |
25
25
  | `references/maintenance-and-revisions.md` | before any structural maintenance edit, for WORK vs MAINTENANCE, the `revisions.md` move-and-record contract, the violation-tag catalog, and the context-economy measurement buckets |
@@ -2,14 +2,19 @@
2
2
 
3
3
  This chapter holds the escape-and-logging depth for each transition gate.
4
4
 
5
- #### Hook naming and ownership
6
-
7
- The `plastic-` prefix on an installed hook launcher is reserved for hooks `HookRegistry`
8
- registers. A user-owned hook must never take it: the installer purges Plastic's registrations
9
- from the agent's hook config on every update, matching by registry launcher name (current plus
10
- `RETIRED_HOOK_NAMES`), and doctor's `hooks_no_orphans` reports any unregistered `plastic-*`
11
- launcher on disk. Renaming or removing a hook from `events` means adding its old name to
12
- `RETIRED_HOOK_NAMES` in the same change, or every existing install keeps a dead registration.
5
+ #### Hook and skill naming and ownership
6
+
7
+ The `plastic-` prefix is reserved for both surfaces: hooks `HookRegistry` registers and skills
8
+ Plastic ships. A user-owned hook or skill must never take it: the installer purges Plastic's
9
+ registrations from the agent's hook config on every update, matching by registry launcher name
10
+ (current plus `RETIRED_HOOK_NAMES`), and doctor reports every violation it can find. On disk,
11
+ `hooks_no_orphans` reports an unregistered `plastic-*` launcher file the registry does not know.
12
+ In the live config, `hooks_entries_owned` (Claude) and `codex_hooks_entries_owned` (Codex)
13
+ report a config entry that is neither a current registration nor recognizably ours, and,
14
+ separately, a current registration whose launcher file is missing from disk. For skills,
15
+ `stray_skills` reports a `plastic-*` skill directory the manifest does not track. Renaming or
16
+ removing a hook from `events` means adding its old name to `RETIRED_HOOK_NAMES` in the same
17
+ change, or every existing install keeps a dead registration.
13
18
 
14
19
  #### The gates by name
15
20
 
@@ -190,6 +190,20 @@ immutable forbids outright. The receipt is instead the scoped git commit
190
190
  durable, diffable record - not a missing safeguard, a deliberate substitution for a receipt
191
191
  shape that would otherwise require an illegal write.
192
192
 
193
+ `--prune` (intent 280) reverses the same tool's direction under the identical carve-out: instead
194
+ of adding newly-violating ids, it removes rows that suppress nothing this run (the intent's gap
195
+ got repaired, the id was mistyped, or the intent directory is gone), computed via the same
196
+ `DoctorExclusions.dead_rows` predicate doctor itself reports from, so the reporter and the
197
+ remover can never disagree about what a dead row is. Same dry-run-by-default, same `--apply`
198
+ gate, same comment-preserving writer, same one scoped commit, same no-`revisions.md`-entry rule -
199
+ this direction still modifies no intent directory, only the store-level table. It holds back two
200
+ kinds of row before writing even when they read as dead: an id whose intent dir carries a fresh
201
+ delivery lock (the lock skip would otherwise leave it out of the walk entirely and misclassify
202
+ it), and an id whose intent has not reached a terminal state yet (`savepoint_operational` only
203
+ fires on a terminal intent, so the row has nothing to suppress *yet*). Both are named in the
204
+ output as kept, never silently dropped, and a rule left with zero ids after pruning is removed
205
+ from the file rather than written as a bare `rule_name` line the loader would reject.
206
+
193
207
  Like every other tool behind `maintenance-run`, it dry-runs by default (the owner-approval
194
208
  gate), unions with any existing hand-edited file content so a manually added id is never
195
209
  dropped, and skips (never aborts on) any intent dir holding a fresh delivery lock.
@@ -218,6 +218,12 @@ folds in the count and the file's path, e.g. `"... (3 excluded via ~/.plastic/do
218
218
  A malformed line in the file forces the check to `warn` with the parse error in `details`, even
219
219
  when zero real gaps remain, so a broken file is never silently permissive.
220
220
 
221
+ **Both surfaces, one line.** A registration is honored by the store-wide `savepoint_operational`
222
+ check and by the per-intent `doctor.rb --intent <id>` run, which reports the same missing
223
+ `savepoint.md` under the check name `intent_savepoint_truthful`. Register the id once. The
224
+ per-intent run honors it only for an intent that is terminal in `INDEX.md`, and never suppresses
225
+ a phantom-savepoint-line finding.
226
+
221
227
  **Hand-editing.** The file is plain text; add a line (or append ids to an existing rule line) and
222
228
  save. No installer step, no reindex, and no `revisions.md` entry is required or written.
223
229
 
@@ -233,6 +239,14 @@ without writing anything. Review the output, then re-run with `--apply` to write
233
239
  land one scoped git commit. It unions with any existing hand-added ids (never drops one) and
234
240
  skips, rather than aborts on, any intent dir holding a fresh delivery lock.
235
241
 
242
+ **Dead-row notice.** A registered row can go dead (gap repaired, id mistyped, or the intent
243
+ directory gone). When any row is dead, the message adds a second suffix next to the exclusion
244
+ count naming the count, the file, and the prune command - purely informational, status and exit
245
+ code unchanged. Prune it the same way, dry-run first: register-exclusions --prune [--apply]. It
246
+ removes exactly the dead rows through the same writer and commit, but holds back an id whose
247
+ intent dir carries a fresh lock or has not gone terminal yet (nothing to suppress there yet),
248
+ naming both as kept.
249
+
236
250
  ## References
237
251
 
238
252
  - Read `references/gates-stuck-detection.md` for the full gate enforcement table, bridge file pattern, and the recorded stuck-detection signals when diagnosing gate failures or stuck agents
@@ -40,6 +40,9 @@ routes each authoring task to the reference that holds the depth.
40
40
  them (use commas, periods, parentheses, colons). Existing internal files and the
41
41
  sanctioned template emissions (templates/index.md's INDEX line shape) are not
42
42
  violations.
43
+ - The `plastic-` prefix is reserved for skills and hooks Plastic itself ships. A skill
44
+ authored outside Plastic's own tree takes a different name; doctor's ownership checks
45
+ and the installer's purge both key off the prefix.
43
46
 
44
47
  ## Route the authoring task to its reference
45
48
 
@@ -58,6 +58,9 @@ Rules [A5]:
58
58
  5. Must not contain `anthropic` or `claude`.
59
59
  6. Prefer the gerund form, which reads as a capability (`processing-pdfs`, `creating-skills`,
60
60
  not `pdf-tool`).
61
+ 7. Never start with `plastic-`. That prefix is reserved for skills and hooks Plastic itself
62
+ ships; doctor's ownership checks (`stray_skills`) and the installer's purge both key off it,
63
+ so a user-authored skill carrying it reads as squatting on Plastic's own namespace.
61
64
 
62
65
  ## The `description` field (triggering)
63
66