claude-dev-env 8.55.0 → 8.56.1
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/docs/account-broker.md +2 -2
- package/docs/templates/artifact-page/README.md +25 -0
- package/docs/templates/artifact-page/template.html +465 -0
- package/hooks/features/session-context.md +4 -1
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +27 -0
- package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +8 -0
- package/hooks/session/artifact_template_pointer.py +102 -0
- package/hooks/session/test_artifact_template_pointer.py +65 -0
- package/package.json +1 -1
package/docs/account-broker.md
CHANGED
|
@@ -15,12 +15,12 @@ python scripts/account_broker.py accounts --product claude
|
|
|
15
15
|
python scripts/account_broker.py choose --product codex
|
|
16
16
|
python scripts/account_broker.py choose --product codex --spent account-name:1790000000
|
|
17
17
|
python scripts/account_broker.py check --product codex
|
|
18
|
-
python scripts/account_broker.py run --product claude --report report.json -- claude --output-format json -p
|
|
18
|
+
printf "%s" "prompt" | python scripts/account_broker.py run --product claude --report report.json -- claude --output-format json -p
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
`accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `home`, `reason`, `tier`, and `resets_at`. A wait decision has no account or home and includes the soonest reset in UTC. A failed meter read excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. A `--spent` name absent from the roster is saved until the supplied Unix reset, or for one hour when no reset is given. While any such mark is still in the future, `choose` waits until the soonest mark and exits 3, and with no Codex roster `--spent default` waits until that mark passes. Meter reads younger than 60 seconds are reused.
|
|
22
22
|
|
|
23
|
-
`check` prints nothing and exits 3 while every account is below its floor. `run` sets `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. A Claude job also starts without the parent session's variables, such as `CLAUDE_CODE_SESSION_ID` and `CLAUDECODE`, so a child started from inside a Claude Code session runs as its own session. `ALL_PARENT_CLAUDE_SESSION_VARIABLES` lists them. It replays the same stdin bytes when a usage limit or start failure leads to another account. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
|
|
23
|
+
`check` prints nothing and exits 3 while every account is below its floor. `run` sets `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. A Claude job also starts without the parent session's variables, such as `CLAUDE_CODE_SESSION_ID` and `CLAUDECODE`, so a child started from inside a Claude Code session runs as its own session. `ALL_PARENT_CLAUDE_SESSION_VARIABLES` lists them. It replays the same stdin bytes when a usage limit or start failure leads to another account. Send the prompt on stdin. When the command resolves to a `.cmd` or `.bat` file, the broker refuses an argument that holds a cmd.exe metacharacter and records `start_failed`. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
|
|
24
24
|
|
|
25
25
|
The broker stores cached meters, spent marks, and Claude session bindings in one JSON file under `~/.claude/account-broker`. Every broker process for the same user reads that file, so a spent mark pauses that account for every job. A write holds an exclusive lock on the sibling `state.json.lock`, re-reads the file, merges, and swaps in a sibling temporary file with `os.replace`. The merge keeps the latest reset for each spent mark and the newest meter read for each account.
|
|
26
26
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Artifact page template
|
|
2
|
+
|
|
3
|
+
`template.html` is the reference page for any artifact an agent builds: a tracker, a board, a review page, a dashboard. Start from it, keep its structure and house style, and replace the sample data and the parts the new page does not need.
|
|
4
|
+
|
|
5
|
+
## What it demonstrates
|
|
6
|
+
|
|
7
|
+
| Part | How the template does it |
|
|
8
|
+
|---|---|
|
|
9
|
+
| One file | The page, its styles, its script and its data live in one HTML file. It opens with no build step and no companion files. |
|
|
10
|
+
| Data block | The sample data sits in `<script type="application/json" id="tracker-data">`. Swap that block for new data. Escape every `</` inside it as `<\/`. |
|
|
11
|
+
| Board first | A board of colored lanes shows where every item stands. Each lane chip jumps to its card. |
|
|
12
|
+
| Picture cards | Each item is a card with a status band, a four-step progress track, a next-step line, a folded summary, links and a copy button. |
|
|
13
|
+
| Filters | Two tabs, a project filter row and a search box narrow the list. The tab and the filter persist per viewer through `localStorage`, wrapped in `try`. |
|
|
14
|
+
| Themes | Every color is a token on `:root`. Dark values apply under `prefers-color-scheme: dark` and under `data-theme="dark"`. |
|
|
15
|
+
| Phone width | One column, at most 640px wide, with a 16px side gutter and no horizontal scroll at 400px. |
|
|
16
|
+
| Readable text | Text is 16px or larger and uses the full text color. Gray stays on lines and borders only. Text holds a 7:1 contrast ratio against its background. |
|
|
17
|
+
| Touch targets | Buttons and chips are 40px or taller, with a visible focus ring. |
|
|
18
|
+
| Copy fallback | The copy button uses the clipboard API and falls back to a hidden text area, then says when the copy is blocked. |
|
|
19
|
+
|
|
20
|
+
## House rules for a page built from it
|
|
21
|
+
|
|
22
|
+
- A control that sends anything selects first, and a separate Send button confirms. A second tap on a picked answer clears it.
|
|
23
|
+
- A page that needs extra files publishes them beside the page through the Artifact `files` field, and links them by relative path.
|
|
24
|
+
- A page that needs live data or saved answers declares the matching runtime capability. `localStorage` holds only per-viewer conveniences.
|
|
25
|
+
- Render the page at 400px in light and dark, and exercise every control, before publishing.
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
<title>Work Tracker</title>
|
|
2
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
3
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
|
4
|
+
<style>
|
|
5
|
+
:root {
|
|
6
|
+
--bg: #f4f5f7; --surface: #ffffff; --fg: #1b2230; --edge: #6b7383; --line: #dde1e8;
|
|
7
|
+
--accent: #2245a2; --accent-soft: #e6ecfb; --accent-ink: #ffffff;
|
|
8
|
+
--ok: #165737; --ok-soft: #e3f3ea; --bad: #8b2621; --bad-soft: #fbe5e3; --warn: #6f4100; --warn-soft: #fbf0dc;
|
|
9
|
+
--violet: #553b95; --violet-soft: #efe9fa;
|
|
10
|
+
--font-body: "Manrope", system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
11
|
+
}
|
|
12
|
+
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
|
|
13
|
+
--bg: #12151b; --surface: #1a1f28; --fg: #e6e9ef; --edge: #6d7891; --line: #2b3240;
|
|
14
|
+
--accent: #a8bfff; --accent-soft: #1f2a45; --accent-ink: #0d1424;
|
|
15
|
+
--ok: #6fd3a0; --ok-soft: #173327; --bad: #ffa8a0; --bad-soft: #3a1c1a; --warn: #f0b860; --warn-soft: #362a14;
|
|
16
|
+
--violet: #c5b1f2; --violet-soft: #251f38;
|
|
17
|
+
color-scheme: dark } }
|
|
18
|
+
:root[data-theme="dark"] {
|
|
19
|
+
--bg: #12151b; --surface: #1a1f28; --fg: #e6e9ef; --edge: #6d7891; --line: #2b3240;
|
|
20
|
+
--accent: #a8bfff; --accent-soft: #1f2a45; --accent-ink: #0d1424;
|
|
21
|
+
--ok: #6fd3a0; --ok-soft: #173327; --bad: #ffa8a0; --bad-soft: #3a1c1a; --warn: #f0b860; --warn-soft: #362a14;
|
|
22
|
+
--violet: #c5b1f2; --violet-soft: #251f38;
|
|
23
|
+
color-scheme: dark }
|
|
24
|
+
* { box-sizing: border-box }
|
|
25
|
+
body { background: var(--bg); color: var(--fg); font-family: var(--font-body); font-size: 17px; line-height: 1.4; padding-inline: 16px; padding-block: 24px 48px; margin: 0 }
|
|
26
|
+
button { font: inherit; color: inherit; touch-action: manipulation; cursor: pointer }
|
|
27
|
+
a { color: var(--accent) }
|
|
28
|
+
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px }
|
|
29
|
+
.wrap { max-width: 640px; margin: 0 auto; display: grid; gap: 16px }
|
|
30
|
+
header { display: grid; gap: 4px }
|
|
31
|
+
h1 { font-size: 26px; font-weight: 800; letter-spacing: -0.02em; margin: 0 }
|
|
32
|
+
.stamp { font-size: 16px; font-weight: 600 }
|
|
33
|
+
.tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 8px }
|
|
34
|
+
.tab { min-height: 52px; border-radius: 12px; border: 2px solid var(--edge); background: var(--surface); font-weight: 800; font-size: 18px; display: flex; align-items: center; justify-content: center; gap: 8px }
|
|
35
|
+
.tab[aria-pressed="true"] { border: 3px solid var(--accent); background: var(--accent-soft); color: var(--accent) }
|
|
36
|
+
.tab .n { font-size: 18px; border-radius: 999px; padding: 0 10px; background: var(--fg); color: var(--bg) }
|
|
37
|
+
.tab[aria-pressed="true"] .n { background: var(--accent); color: var(--accent-ink) }
|
|
38
|
+
.board { display: grid; gap: 8px }
|
|
39
|
+
.lane { border-radius: 14px; padding: 12px; display: grid; gap: 10px; border: 2px solid transparent }
|
|
40
|
+
.lane.you { background: var(--warn-soft) } .lane.you .lh { color: var(--warn) }
|
|
41
|
+
.lane.blocked { background: var(--bad-soft) } .lane.blocked .lh { color: var(--bad) }
|
|
42
|
+
.lane.doing { background: var(--violet-soft) } .lane.doing .lh { color: var(--violet) }
|
|
43
|
+
.lane.ready { background: var(--accent-soft) } .lane.ready .lh { color: var(--accent) }
|
|
44
|
+
.lane.done { background: var(--ok-soft) } .lane.done .lh { color: var(--ok) }
|
|
45
|
+
.lh { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 19px }
|
|
46
|
+
.lh .ic { font-size: 22px; line-height: 1 }
|
|
47
|
+
.lh .count { margin-left: auto; font-size: 26px; font-variant-numeric: tabular-nums }
|
|
48
|
+
.lchips { display: flex; flex-wrap: wrap; gap: 6px }
|
|
49
|
+
.lchip { background: var(--surface); border: 2px solid var(--line); border-radius: 999px; padding: 4px 12px; font-size: 16px; font-weight: 700; min-height: 40px; text-align: left }
|
|
50
|
+
.filters { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin }
|
|
51
|
+
.fchip { flex: none; min-height: 44px; border-radius: 999px; border: 2px solid var(--edge); background: var(--surface); padding: 4px 14px; font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 6px; white-space: nowrap }
|
|
52
|
+
.fchip[aria-pressed="true"] { border: 3px solid var(--accent); background: var(--accent-soft); color: var(--accent); padding: 3px 13px }
|
|
53
|
+
input[type="search"] { font: inherit; font-size: 17px; width: 100%; min-height: 48px; background: var(--surface); color: var(--fg); border: 2px solid var(--edge); border-radius: 12px; padding: 8px 14px }
|
|
54
|
+
input[type="search"]::placeholder { color: var(--fg); opacity: 1 }
|
|
55
|
+
.section-h { font-size: 20px; font-weight: 800; margin: 8px 0 0; display: flex; align-items: center; gap: 8px }
|
|
56
|
+
.list { display: grid; gap: 12px }
|
|
57
|
+
.card { background: var(--surface); border: 2px solid var(--line); border-radius: 16px; overflow: hidden; display: grid; min-width: 0; scroll-margin-top: 16px }
|
|
58
|
+
.card.you { border: 3px solid var(--warn) }
|
|
59
|
+
.card.blocked { border: 3px solid var(--bad) }
|
|
60
|
+
.card.flash { box-shadow: 0 0 0 4px var(--accent) }
|
|
61
|
+
.band { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-weight: 800; font-size: 16px }
|
|
62
|
+
.band.you { background: var(--warn-soft); color: var(--warn) }
|
|
63
|
+
.band.blocked { background: var(--bad-soft); color: var(--bad) }
|
|
64
|
+
.band.doing { background: var(--violet-soft); color: var(--violet) }
|
|
65
|
+
.band.ready { background: var(--accent-soft); color: var(--accent) }
|
|
66
|
+
.band.done { background: var(--ok-soft); color: var(--ok) }
|
|
67
|
+
.band .proj { margin-left: auto; display: flex; align-items: center; gap: 6px; color: var(--fg); font-weight: 700; text-align: right }
|
|
68
|
+
.body { padding: 12px; display: grid; gap: 12px; min-width: 0 }
|
|
69
|
+
.title { font-size: 21px; font-weight: 800; margin: 0; overflow-wrap: anywhere }
|
|
70
|
+
.track { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); position: relative }
|
|
71
|
+
.track::before { content: ""; position: absolute; left: 12.5%; right: 12.5%; top: 15px; height: 3px; background: var(--edge) }
|
|
72
|
+
.track li { display: grid; justify-items: center; gap: 4px; text-align: center; font-size: 16px; font-weight: 700; position: relative; min-width: 0 }
|
|
73
|
+
.track .dot { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; font-size: 16px; background: var(--surface); border: 3px solid var(--edge) }
|
|
74
|
+
.track li.done .dot { background: var(--ok); border-color: var(--ok); color: var(--surface) }
|
|
75
|
+
.track li.now .dot { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); box-shadow: 0 0 0 4px var(--accent-soft) }
|
|
76
|
+
.track li.now { color: var(--accent); font-weight: 800 }
|
|
77
|
+
.card.you .track li.now { color: var(--warn) }
|
|
78
|
+
.card.you .track li.now .dot { background: var(--warn); border-color: var(--warn); color: var(--surface); box-shadow: 0 0 0 4px var(--warn-soft) }
|
|
79
|
+
.next { display: flex; gap: 10px; align-items: flex-start; background: var(--bg); border-radius: 12px; padding: 10px 12px; font-weight: 600; overflow-wrap: anywhere }
|
|
80
|
+
.next .ic { font-size: 20px; line-height: 1.2; flex: none }
|
|
81
|
+
details.what { border-top: 2px solid var(--line); padding-top: 8px }
|
|
82
|
+
details.what summary { font-weight: 800; cursor: pointer; min-height: 40px; display: flex; align-items: center; gap: 8px; list-style: none }
|
|
83
|
+
details.what summary::-webkit-details-marker { display: none }
|
|
84
|
+
details.what summary::before { content: "▸"; font-size: 18px }
|
|
85
|
+
details.what[open] summary::before { content: "▾" }
|
|
86
|
+
.points { margin: 6px 0 0; padding: 0; list-style: none; display: grid; gap: 8px }
|
|
87
|
+
.points li { display: flex; gap: 8px; overflow-wrap: anywhere }
|
|
88
|
+
.points li::before { content: "✓"; color: var(--ok); font-weight: 800; flex: none }
|
|
89
|
+
.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center }
|
|
90
|
+
.link { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; border: 2px solid var(--accent); border-radius: 999px; padding: 4px 14px; font-weight: 700; font-size: 16px; text-decoration: none; color: var(--accent); background: var(--surface) }
|
|
91
|
+
.copy { min-height: 44px; border-radius: 999px; border: 2px solid var(--fg); background: var(--surface); padding: 4px 14px; font-weight: 800; font-size: 16px; margin-left: auto }
|
|
92
|
+
.copy.ok { border-color: var(--ok); color: var(--ok) }
|
|
93
|
+
.when { font-size: 16px; font-weight: 600 }
|
|
94
|
+
.steps { display: grid; gap: 8px; border-top: 2px solid var(--line); padding-top: 10px }
|
|
95
|
+
.steps .sh { font-weight: 800 }
|
|
96
|
+
.step { display: flex; gap: 10px; align-items: flex-start }
|
|
97
|
+
.step .num { flex: none; width: 30px; height: 30px; border-radius: 50%; background: var(--warn); color: var(--surface); display: grid; place-items: center; font-weight: 800 }
|
|
98
|
+
.empty { background: var(--surface); border: 2px dashed var(--edge); border-radius: 14px; padding: 20px; font-weight: 600 }
|
|
99
|
+
@media (hover: hover) { .lchip:hover, .fchip:hover, .tab:hover, .copy:hover { border-color: var(--accent) } }
|
|
100
|
+
</style>
|
|
101
|
+
|
|
102
|
+
<div class="wrap">
|
|
103
|
+
<header>
|
|
104
|
+
<h1>Work Tracker</h1>
|
|
105
|
+
<div class="stamp" id="stamp"></div>
|
|
106
|
+
</header>
|
|
107
|
+
<div class="tabs" role="group" aria-label="Show open or finished items">
|
|
108
|
+
<button class="tab" data-tab="open" aria-pressed="true">Open <span class="n" id="n-open"></span></button>
|
|
109
|
+
<button class="tab" data-tab="done" aria-pressed="false">Done <span class="n" id="n-done"></span></button>
|
|
110
|
+
</div>
|
|
111
|
+
<section class="board" id="board" aria-label="Where items stand"></section>
|
|
112
|
+
<div class="filters" id="filters" role="group" aria-label="Filter by project"></div>
|
|
113
|
+
<input type="search" id="search" placeholder="Search items" aria-label="Search items">
|
|
114
|
+
<main class="list" id="list"></main>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<script type="application/json" id="tracker-data">
|
|
118
|
+
{
|
|
119
|
+
"updated": "2026-01-15T17:30:00Z",
|
|
120
|
+
"owners": [
|
|
121
|
+
{
|
|
122
|
+
"key": "web",
|
|
123
|
+
"name": "Web App",
|
|
124
|
+
"icon": "🌐"
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"key": "platform",
|
|
128
|
+
"name": "Platform",
|
|
129
|
+
"icon": "🧩"
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"key": "docs",
|
|
133
|
+
"name": "Documentation",
|
|
134
|
+
"icon": "📚"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"key": "new-project",
|
|
138
|
+
"name": "New projects",
|
|
139
|
+
"icon": "✨"
|
|
140
|
+
}
|
|
141
|
+
],
|
|
142
|
+
"items": [
|
|
143
|
+
{
|
|
144
|
+
"title": "Pick a release date",
|
|
145
|
+
"owner": "web",
|
|
146
|
+
"status": "open",
|
|
147
|
+
"needs_you": true,
|
|
148
|
+
"card": true,
|
|
149
|
+
"next": "choose between the two dates in the linked plan.",
|
|
150
|
+
"summary": [
|
|
151
|
+
"One release date everyone plans around",
|
|
152
|
+
"The launch checklist dated backward from it",
|
|
153
|
+
"A reminder a week before"
|
|
154
|
+
],
|
|
155
|
+
"added": "2026-01-14T15:05:00Z",
|
|
156
|
+
"links": [
|
|
157
|
+
{
|
|
158
|
+
"label": "Release plan",
|
|
159
|
+
"url": "https://example.com/release-plan"
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
"starter": "Pick a release date. Read the release plan, list both dates with what each one moves, and ask for the choice."
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"title": "Flaky login test",
|
|
166
|
+
"owner": "web",
|
|
167
|
+
"status": "blocked",
|
|
168
|
+
"card": true,
|
|
169
|
+
"next": "wait for the staging database to come back online.",
|
|
170
|
+
"summary": [
|
|
171
|
+
"The login test passes ten runs in a row",
|
|
172
|
+
"The root cause written in the pull request"
|
|
173
|
+
],
|
|
174
|
+
"added": "2026-01-13T19:20:00Z",
|
|
175
|
+
"links": [
|
|
176
|
+
{
|
|
177
|
+
"label": "Issue 42",
|
|
178
|
+
"url": "https://example.com/issues/42"
|
|
179
|
+
}
|
|
180
|
+
],
|
|
181
|
+
"starter": "Fix the flaky login test. Reproduce it, find the cause, and open a draft pull request with the proof."
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"title": "Faster search page",
|
|
185
|
+
"owner": "platform",
|
|
186
|
+
"status": "doing",
|
|
187
|
+
"card": true,
|
|
188
|
+
"next": "finish the index change and rerun the timing check.",
|
|
189
|
+
"summary": [
|
|
190
|
+
"Search answers in under one second",
|
|
191
|
+
"A timing check that guards it",
|
|
192
|
+
"No change to the results people see"
|
|
193
|
+
],
|
|
194
|
+
"added": "2026-01-12T14:00:00Z",
|
|
195
|
+
"links": [
|
|
196
|
+
{
|
|
197
|
+
"label": "Pull request 108",
|
|
198
|
+
"url": "https://example.com/pulls/108"
|
|
199
|
+
}
|
|
200
|
+
],
|
|
201
|
+
"starter": "Make the search page faster. Measure first, change the index, and prove the new time."
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"title": "Setup guide refresh",
|
|
205
|
+
"owner": "docs",
|
|
206
|
+
"status": "open",
|
|
207
|
+
"card": true,
|
|
208
|
+
"next": "start a builder on the card.",
|
|
209
|
+
"summary": [
|
|
210
|
+
"Every setup step runs as written on a clean machine",
|
|
211
|
+
"Screens match the current app"
|
|
212
|
+
],
|
|
213
|
+
"added": "2026-01-15T16:45:00Z",
|
|
214
|
+
"starter": "Refresh the setup guide. Run each step on a clean machine and fix every step that fails."
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"title": "Status page",
|
|
218
|
+
"owner": "new-project",
|
|
219
|
+
"status": "open",
|
|
220
|
+
"card": false,
|
|
221
|
+
"next": "create the project, then hand it the setup brief.",
|
|
222
|
+
"summary": [
|
|
223
|
+
"A public page that shows service health",
|
|
224
|
+
"An alert when a check fails"
|
|
225
|
+
],
|
|
226
|
+
"added": "2026-01-15T17:10:00Z",
|
|
227
|
+
"proposal": {
|
|
228
|
+
"name": "Status Page"
|
|
229
|
+
},
|
|
230
|
+
"owner_steps": [
|
|
231
|
+
"Create a project named Status Page.",
|
|
232
|
+
"Attach the repository it should watch.",
|
|
233
|
+
"Paste the copied starter as its first message."
|
|
234
|
+
],
|
|
235
|
+
"starter": "Set up the Status Page project from its brief, then build the first health check."
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
"title": "Dark mode colors",
|
|
239
|
+
"owner": "web",
|
|
240
|
+
"status": "done",
|
|
241
|
+
"card": true,
|
|
242
|
+
"next": "merged; every screen passes the contrast check in both themes.",
|
|
243
|
+
"summary": [
|
|
244
|
+
"Every color token has a dark value",
|
|
245
|
+
"Text keeps a 7:1 contrast ratio"
|
|
246
|
+
],
|
|
247
|
+
"added": "2026-01-08T13:00:00Z",
|
|
248
|
+
"done_at": "2026-01-11T21:40:00Z",
|
|
249
|
+
"links": [
|
|
250
|
+
{
|
|
251
|
+
"label": "Pull request 97",
|
|
252
|
+
"url": "https://example.com/pulls/97"
|
|
253
|
+
}
|
|
254
|
+
]
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"title": "Changelog automation",
|
|
258
|
+
"owner": "platform",
|
|
259
|
+
"status": "done",
|
|
260
|
+
"card": true,
|
|
261
|
+
"next": "merged; each release writes its own changelog.",
|
|
262
|
+
"summary": [
|
|
263
|
+
"Release notes come from merged pull requests",
|
|
264
|
+
"No hand-edited changelog"
|
|
265
|
+
],
|
|
266
|
+
"added": "2026-01-05T15:30:00Z",
|
|
267
|
+
"done_at": "2026-01-09T18:15:00Z",
|
|
268
|
+
"links": [
|
|
269
|
+
{
|
|
270
|
+
"label": "Pull request 90",
|
|
271
|
+
"url": "https://example.com/pulls/90"
|
|
272
|
+
}
|
|
273
|
+
]
|
|
274
|
+
}
|
|
275
|
+
]
|
|
276
|
+
}
|
|
277
|
+
</script>
|
|
278
|
+
<script>
|
|
279
|
+
const TRACKER = JSON.parse(document.getElementById("tracker-data").textContent);
|
|
280
|
+
const STAGES = [
|
|
281
|
+
{ key: "you", label: "Needs you", icon: "✋" },
|
|
282
|
+
{ key: "blocked", label: "Blocked", icon: "⛔" },
|
|
283
|
+
{ key: "doing", label: "In progress", icon: "🔨" },
|
|
284
|
+
{ key: "ready", label: "Ready to build", icon: "📋" },
|
|
285
|
+
{ key: "done", label: "Done", icon: "✅" }
|
|
286
|
+
];
|
|
287
|
+
const STAGE_BY_KEY = Object.fromEntries(STAGES.map(s => [s.key, s]));
|
|
288
|
+
const TRACK = ["Card", "Builder", "Pull request", "Merged"];
|
|
289
|
+
const state = { tab: "open", owner: "all", query: "" };
|
|
290
|
+
try { const saved = JSON.parse(localStorage.getItem("work-tracker-view") || "{}"); if (saved.tab) state.tab = saved.tab; if (saved.owner) state.owner = saved.owner; } catch (e) {}
|
|
291
|
+
const ownerByKey = Object.fromEntries(TRACKER.owners.map(o => [o.key, o]));
|
|
292
|
+
const isDone = it => it.status === "done";
|
|
293
|
+
function stageOf(it) {
|
|
294
|
+
if (it.status === "done") return "done";
|
|
295
|
+
if (it.status === "blocked") return "blocked";
|
|
296
|
+
if (it.needs_you) return "you";
|
|
297
|
+
if (it.status === "doing") return "doing";
|
|
298
|
+
return "ready";
|
|
299
|
+
}
|
|
300
|
+
const slug = it => "item-" + it.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
301
|
+
const fmt = iso => { if (!iso) return ""; try { return new Date(iso).toLocaleString(undefined, { month: "long", day: "numeric", hour: "numeric", minute: "2-digit" }); } catch (e) { return iso; } };
|
|
302
|
+
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
|
|
303
|
+
function save() { try { localStorage.setItem("work-tracker-view", JSON.stringify({ tab: state.tab, owner: state.owner })); } catch (e) {} }
|
|
304
|
+
function inView(it) {
|
|
305
|
+
if ((state.tab === "done") !== isDone(it)) return false;
|
|
306
|
+
if (state.owner !== "all" && it.owner !== state.owner) return false;
|
|
307
|
+
const q = state.query.trim().toLowerCase();
|
|
308
|
+
return !q || (it.title + " " + [].concat(it.summary || []).join(" ") + " " + (it.next || "")).toLowerCase().includes(q);
|
|
309
|
+
}
|
|
310
|
+
function jumpTo(it) {
|
|
311
|
+
const card = document.getElementById(slug(it));
|
|
312
|
+
if (!card) return;
|
|
313
|
+
card.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
314
|
+
card.classList.add("flash");
|
|
315
|
+
setTimeout(() => card.classList.remove("flash"), 1600);
|
|
316
|
+
}
|
|
317
|
+
function renderBoard(items) {
|
|
318
|
+
const board = document.getElementById("board"); board.textContent = "";
|
|
319
|
+
const keys = state.tab === "done" ? ["done"] : ["you", "blocked", "doing", "ready"];
|
|
320
|
+
for (const key of keys) {
|
|
321
|
+
const mine = items.filter(i => stageOf(i) === key);
|
|
322
|
+
if (!mine.length && (key === "blocked" || key === "you")) continue;
|
|
323
|
+
const s = STAGE_BY_KEY[key];
|
|
324
|
+
const lane = el("div", "lane " + key);
|
|
325
|
+
const head = el("div", "lh");
|
|
326
|
+
head.append(el("span", "ic", s.icon), el("span", null, s.label));
|
|
327
|
+
head.append(el("span", "count", String(mine.length)));
|
|
328
|
+
lane.append(head);
|
|
329
|
+
if (mine.length) {
|
|
330
|
+
const chips = el("div", "lchips");
|
|
331
|
+
for (const it of mine) {
|
|
332
|
+
const b = el("button", "lchip", it.title);
|
|
333
|
+
b.setAttribute("aria-label", "Go to " + it.title);
|
|
334
|
+
b.onclick = () => jumpTo(it);
|
|
335
|
+
chips.append(b);
|
|
336
|
+
}
|
|
337
|
+
lane.append(chips);
|
|
338
|
+
}
|
|
339
|
+
board.append(lane);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function renderFilters() {
|
|
343
|
+
const f = document.getElementById("filters"); f.textContent = "";
|
|
344
|
+
const pool = TRACKER.items.filter(i => (state.tab === "done") === isDone(i));
|
|
345
|
+
const opts = [{ key: "all", name: "All projects" }].concat(TRACKER.owners.filter(o => pool.some(i => i.owner === o.key)));
|
|
346
|
+
if (!opts.some(o => o.key === state.owner)) state.owner = "all";
|
|
347
|
+
for (const o of opts) {
|
|
348
|
+
const b = el("button", "fchip");
|
|
349
|
+
if (o.key !== "all") b.append(el("span", null, (ownerByKey[o.key].icon || "📁")));
|
|
350
|
+
b.append(el("span", null, o.name));
|
|
351
|
+
if (o.key !== "all") b.append(el("b", null, String(pool.filter(i => i.owner === o.key).length)));
|
|
352
|
+
b.setAttribute("aria-pressed", String(state.owner === o.key));
|
|
353
|
+
b.onclick = () => { state.owner = o.key; save(); render(); };
|
|
354
|
+
f.append(b);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function trackFor(it) {
|
|
358
|
+
const ol = el("ol", "track");
|
|
359
|
+
ol.setAttribute("aria-label", "Progress");
|
|
360
|
+
let now;
|
|
361
|
+
if (it.status === "done") now = TRACK.length;
|
|
362
|
+
else if (it.status === "doing") now = 2;
|
|
363
|
+
else now = it.card ? 1 : 0;
|
|
364
|
+
TRACK.forEach((label, i) => {
|
|
365
|
+
const li = el("li", i < now ? "done" : i === now ? "now" : "");
|
|
366
|
+
li.append(el("span", "dot", i < now ? "✓" : String(i + 1)), el("span", null, label));
|
|
367
|
+
ol.append(li);
|
|
368
|
+
});
|
|
369
|
+
return ol;
|
|
370
|
+
}
|
|
371
|
+
function copyButton(it) {
|
|
372
|
+
const cp = el("button", "copy", "Copy starter");
|
|
373
|
+
cp.setAttribute("aria-label", "Copy the handoff for " + it.title);
|
|
374
|
+
const reset = () => { cp.textContent = "Copy starter"; cp.classList.remove("ok"); };
|
|
375
|
+
cp.onclick = () => {
|
|
376
|
+
const text = it.starter || it.title;
|
|
377
|
+
const done = () => { cp.textContent = "Copied ✓"; cp.classList.add("ok"); setTimeout(reset, 1800); };
|
|
378
|
+
const fallback = () => {
|
|
379
|
+
const area = document.createElement("textarea");
|
|
380
|
+
area.value = text; area.setAttribute("readonly", ""); area.style.position = "fixed"; area.style.opacity = "0";
|
|
381
|
+
document.body.append(area); area.select();
|
|
382
|
+
let copied = false; try { copied = document.execCommand("copy"); } catch (e) {}
|
|
383
|
+
area.remove();
|
|
384
|
+
if (copied) done(); else cp.textContent = "Copy blocked";
|
|
385
|
+
};
|
|
386
|
+
if (navigator.clipboard) navigator.clipboard.writeText(text).then(done, fallback); else fallback();
|
|
387
|
+
};
|
|
388
|
+
return cp;
|
|
389
|
+
}
|
|
390
|
+
function itemCard(it) {
|
|
391
|
+
const key = stageOf(it), s = STAGE_BY_KEY[key], owner = ownerByKey[it.owner] || { name: it.owner };
|
|
392
|
+
const card = el("article", "card " + key);
|
|
393
|
+
card.id = slug(it);
|
|
394
|
+
const band = el("div", "band " + key);
|
|
395
|
+
band.append(el("span", null, s.icon), el("span", null, s.label));
|
|
396
|
+
const proj = el("span", "proj");
|
|
397
|
+
proj.append(el("span", null, (owner.icon || "📁")), el("span", null, owner.name));
|
|
398
|
+
band.append(proj);
|
|
399
|
+
card.append(band);
|
|
400
|
+
const body = el("div", "body");
|
|
401
|
+
body.append(el("h3", "title", it.title));
|
|
402
|
+
body.append(trackFor(it));
|
|
403
|
+
if (it.next) {
|
|
404
|
+
const next = el("div", "next");
|
|
405
|
+
next.append(el("span", "ic", isDone(it) ? "✅" : "👉"), el("span", null, it.next.charAt(0).toUpperCase() + it.next.slice(1)));
|
|
406
|
+
body.append(next);
|
|
407
|
+
}
|
|
408
|
+
const points = [].concat(it.summary || []);
|
|
409
|
+
if (points.length) {
|
|
410
|
+
const d = el("details", "what");
|
|
411
|
+
d.append(el("summary", null, "What it delivers"));
|
|
412
|
+
const ul = el("ul", "points");
|
|
413
|
+
for (const p of points) ul.append(el("li", null, p));
|
|
414
|
+
d.append(ul);
|
|
415
|
+
body.append(d);
|
|
416
|
+
}
|
|
417
|
+
if (it.proposal && !isDone(it) && it.owner_steps && it.owner_steps.length) {
|
|
418
|
+
const st = el("div", "steps");
|
|
419
|
+
st.append(el("div", "sh", "Your steps to create " + it.proposal.name));
|
|
420
|
+
it.owner_steps.forEach((text, i) => { const row = el("div", "step"); row.append(el("span", "num", String(i + 1)), el("span", null, text)); st.append(row); });
|
|
421
|
+
body.append(st);
|
|
422
|
+
}
|
|
423
|
+
const actions = el("div", "actions");
|
|
424
|
+
for (const l of it.links || []) {
|
|
425
|
+
const a = el("a", "link");
|
|
426
|
+
a.append(el("span", null, "🔗"), el("span", null, l.label));
|
|
427
|
+
a.href = l.url; a.target = "_blank"; a.rel = "noopener";
|
|
428
|
+
actions.append(a);
|
|
429
|
+
}
|
|
430
|
+
if (!isDone(it)) actions.append(copyButton(it));
|
|
431
|
+
body.append(actions);
|
|
432
|
+
body.append(el("div", "when", isDone(it) ? "Done " + fmt(it.done_at) : "Added " + fmt(it.added)));
|
|
433
|
+
card.append(body);
|
|
434
|
+
return card;
|
|
435
|
+
}
|
|
436
|
+
function render() {
|
|
437
|
+
document.querySelectorAll(".tab").forEach(t => t.setAttribute("aria-pressed", String(t.dataset.tab === state.tab)));
|
|
438
|
+
const open = TRACKER.items.filter(i => !isDone(i));
|
|
439
|
+
document.getElementById("stamp").textContent = "Updated " + fmt(TRACKER.updated);
|
|
440
|
+
document.getElementById("n-open").textContent = open.length;
|
|
441
|
+
document.getElementById("n-done").textContent = TRACKER.items.length - open.length;
|
|
442
|
+
renderFilters();
|
|
443
|
+
const items = TRACKER.items.filter(inView);
|
|
444
|
+
renderBoard(items);
|
|
445
|
+
const list = document.getElementById("list"); list.textContent = "";
|
|
446
|
+
if (!items.length) {
|
|
447
|
+
list.append(el("div", "empty", TRACKER.items.length ? (state.tab === "done" ? "No finished items match." : "No open items match.") : "Nothing tracked yet. Add an item to the data block and it shows up here."));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const order = state.tab === "done" ? ["done"] : ["you", "blocked", "doing", "ready"];
|
|
451
|
+
for (const key of order) {
|
|
452
|
+
const mine = items.filter(i => stageOf(i) === key);
|
|
453
|
+
if (!mine.length) continue;
|
|
454
|
+
mine.sort((a, b) => key === "done" ? (b.done_at || "").localeCompare(a.done_at || "") : (a.added || "").localeCompare(b.added || ""));
|
|
455
|
+
const s = STAGE_BY_KEY[key];
|
|
456
|
+
const h = el("h2", "section-h");
|
|
457
|
+
h.append(el("span", null, s.icon), el("span", null, s.label + " · " + mine.length));
|
|
458
|
+
list.append(h);
|
|
459
|
+
mine.forEach(i => list.append(itemCard(i)));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
document.querySelectorAll(".tab").forEach(t => t.onclick = () => { state.tab = t.dataset.tab; save(); render(); });
|
|
463
|
+
document.getElementById("search").oninput = e => { state.query = e.target.value; render(); };
|
|
464
|
+
render();
|
|
465
|
+
</script>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Session context
|
|
2
2
|
|
|
3
|
-
This family injects reminders and task guidance when an agent starts, resumes, submits a prompt, spawns a helper, or hits an auto mode denial. The startup scripts leave the session quiet when their opt-in conditions fail.
|
|
3
|
+
This family injects reminders and task guidance when an agent starts, resumes, submits a prompt, spawns a helper, starts an artifact page, or hits an auto mode denial. The startup scripts leave the session quiet when their opt-in conditions fail.
|
|
4
4
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
@@ -10,12 +10,14 @@ This family injects reminders and task guidance when an agent starts, resumes, s
|
|
|
10
10
|
- `session/advisor_rules_prompt.py` injects advisor guidance when the built-in advisor is enabled.
|
|
11
11
|
- `session/orchestrator_auto_starter.py` injects an orchestrator directive when its environment flag is enabled.
|
|
12
12
|
- `session/issue_tracker_session_starter.py` injects issue tracker guidance when enabled for a registered repository.
|
|
13
|
+
- `session/artifact_template_pointer.py` points an agent that starts an artifact page at `docs/templates/artifact-page/template.html`.
|
|
13
14
|
- `advisory/auto_mode_denial_quick_fix.py` proposes one `autoMode.allow` entry and a PowerShell block that writes it after an auto mode denial. It never retries the denied call.
|
|
14
15
|
|
|
15
16
|
## When it fires
|
|
16
17
|
|
|
17
18
|
- `session/skill_loaded_reminder.py` runs on `PreToolUse` matcher `Agent|Task` at `10` seconds, `UserPromptSubmit` matcher empty at `10` seconds, `SessionStart` matcher `compact` at `10` seconds, and `SubagentStart` matcher `workflow-subagent` at `10` seconds in `hooks.json`.
|
|
18
19
|
- `session/task_tool_prompt.py`, `session/working_style_prompt.py`, `session/advisor_rules_prompt.py`, `session/orchestrator_auto_starter.py`, and `session/issue_tracker_session_starter.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
|
|
20
|
+
- `session/artifact_template_pointer.py` runs on `PreToolUse` matcher `Artifact|Skill` at `10` seconds in `hooks.json`. It speaks only on an `Artifact` call with action `quickstart` or a `Skill` call that loads `artifact-design`.
|
|
19
21
|
- `advisory/auto_mode_denial_quick_fix.py` runs on `PermissionDenied`, matcher `*`, timeout `10` seconds in `hooks.json`.
|
|
20
22
|
|
|
21
23
|
## Proving it
|
|
@@ -30,6 +32,7 @@ Preconditions:
|
|
|
30
32
|
- **Advisor guidance.** Input is SessionStart with `advisorModel` set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_advisor_rules_prompt.py -q`. The adjacent test observes guidance only when the advisor setting is enabled.
|
|
31
33
|
- **Orchestrator start.** Input is SessionStart with its opt-in flag set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_orchestrator_auto_starter.py -q`. The adjacent test observes an orchestrator directive.
|
|
32
34
|
- **Issue tracker start.** Input is SessionStart with its opt-in flag and a registered checkout. Run `python -m pytest packages/claude-dev-env/hooks/session/test_issue_tracker_session_starter.py -q`. The adjacent test observes issue tracker context.
|
|
35
|
+
- **Artifact template pointer.** Input is an `Artifact` quickstart call. Run `python -m pytest packages/claude-dev-env/hooks/session/test_artifact_template_pointer.py -q`. The adjacent test observes the template path in `additionalContext`, and silence on a publish call or another skill.
|
|
33
36
|
- **Denial quick fix.** Input is a `PermissionDenied` event with a bracketed rule label and a classifier verdict. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_auto_mode_denial_quick_fix.py -q`. The adjacent test observes the named rule, the allow entry, and the PowerShell block; a denial with no verdict gets a note and no block.
|
|
34
37
|
|
|
35
38
|
## Gotchas
|
package/hooks/hooks.json
CHANGED
|
@@ -127,6 +127,16 @@
|
|
|
127
127
|
}
|
|
128
128
|
]
|
|
129
129
|
},
|
|
130
|
+
{
|
|
131
|
+
"matcher": "Artifact|Skill",
|
|
132
|
+
"hooks": [
|
|
133
|
+
{
|
|
134
|
+
"type": "command",
|
|
135
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/artifact_template_pointer.py",
|
|
136
|
+
"timeout": 10
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
},
|
|
130
140
|
{
|
|
131
141
|
"matcher": "mcp__hearthbot__start_thread_session",
|
|
132
142
|
"hooks": [
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Names and pointer text for the artifact_template_pointer PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ARTIFACT_ACTION_INPUT_KEY",
|
|
9
|
+
"ARTIFACT_QUICKSTART_ACTION",
|
|
10
|
+
"ARTIFACT_TEMPLATE_PATH",
|
|
11
|
+
"ARTIFACT_TOOL_NAME",
|
|
12
|
+
"POINTER_TEXT_PREFIX",
|
|
13
|
+
"SKILL_NAME_INPUT_KEY",
|
|
14
|
+
"SKILL_TOOL_NAME",
|
|
15
|
+
"ALL_ARTIFACT_DESIGN_SKILL_NAMES",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
ARTIFACT_TOOL_NAME = "Artifact"
|
|
19
|
+
ARTIFACT_ACTION_INPUT_KEY = "action"
|
|
20
|
+
ARTIFACT_QUICKSTART_ACTION = "quickstart"
|
|
21
|
+
SKILL_TOOL_NAME = "Skill"
|
|
22
|
+
SKILL_NAME_INPUT_KEY = "skill"
|
|
23
|
+
ALL_ARTIFACT_DESIGN_SKILL_NAMES = frozenset({"artifact-design"})
|
|
24
|
+
ARTIFACT_TEMPLATE_PATH = (
|
|
25
|
+
Path(__file__).resolve().parents[2] / "docs" / "templates" / "artifact-page" / "template.html"
|
|
26
|
+
)
|
|
27
|
+
POINTER_TEXT_PREFIX = "Build this artifact page from the template at "
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Behavioral tests for the artifact template pointer's path."""
|
|
2
|
+
|
|
3
|
+
from hooks_constants.artifact_template_pointer_constants import ARTIFACT_TEMPLATE_PATH
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_template_path_names_the_packaged_template_file() -> None:
|
|
7
|
+
assert ARTIFACT_TEMPLATE_PATH.is_file()
|
|
8
|
+
assert ARTIFACT_TEMPLATE_PATH.parent.joinpath("README.md").is_file()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: point an agent about to build an artifact page at the template.
|
|
3
|
+
|
|
4
|
+
Registered on ``Artifact|Skill``. It adds one line of ``additionalContext``
|
|
5
|
+
naming ``docs/templates/artifact-page/template.html`` when a session starts a
|
|
6
|
+
new artifact page:
|
|
7
|
+
|
|
8
|
+
::
|
|
9
|
+
|
|
10
|
+
Artifact {"action": "quickstart", ...} -> the pointer line
|
|
11
|
+
Skill {"skill": "artifact-design"} -> the pointer line
|
|
12
|
+
Artifact {"action": "publish", ...} -> no output
|
|
13
|
+
Skill {"skill": "pr-lifecycle"} -> no output
|
|
14
|
+
|
|
15
|
+
The call runs with its normal permission flow.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
25
|
+
if hooks_root_directory not in sys.path:
|
|
26
|
+
sys.path.insert(0, hooks_root_directory)
|
|
27
|
+
|
|
28
|
+
from hooks_constants.artifact_template_pointer_constants import (
|
|
29
|
+
ALL_ARTIFACT_DESIGN_SKILL_NAMES,
|
|
30
|
+
ARTIFACT_ACTION_INPUT_KEY,
|
|
31
|
+
ARTIFACT_QUICKSTART_ACTION,
|
|
32
|
+
ARTIFACT_TEMPLATE_PATH,
|
|
33
|
+
ARTIFACT_TOOL_NAME,
|
|
34
|
+
POINTER_TEXT_PREFIX,
|
|
35
|
+
SKILL_NAME_INPUT_KEY,
|
|
36
|
+
SKILL_TOOL_NAME,
|
|
37
|
+
)
|
|
38
|
+
from hooks_constants.hook_specific_output_keys import (
|
|
39
|
+
HOOK_EVENT_NAME_KEY,
|
|
40
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
41
|
+
)
|
|
42
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
43
|
+
from hooks_constants.spawn_readiness_hook_constants import (
|
|
44
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
45
|
+
PRE_TOOL_USE_EVENT_NAME,
|
|
46
|
+
TOOL_INPUT_KEY,
|
|
47
|
+
TOOL_NAME_KEY,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def starts_an_artifact_page(tool_name: object, tool_input: object) -> bool:
|
|
52
|
+
"""Return True when the call is the first step of building an artifact page.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
tool_name: The tool the session called.
|
|
56
|
+
tool_input: The tool input.
|
|
57
|
+
"""
|
|
58
|
+
if not isinstance(tool_input, dict):
|
|
59
|
+
return False
|
|
60
|
+
if tool_name == ARTIFACT_TOOL_NAME:
|
|
61
|
+
return tool_input.get(ARTIFACT_ACTION_INPUT_KEY) == ARTIFACT_QUICKSTART_ACTION
|
|
62
|
+
if tool_name == SKILL_TOOL_NAME:
|
|
63
|
+
return tool_input.get(SKILL_NAME_INPUT_KEY) in ALL_ARTIFACT_DESIGN_SKILL_NAMES
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object] | None:
|
|
68
|
+
"""Return the pointer as additionalContext output, or None to stay quiet.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
all_hook_fields: The parsed PreToolUse payload.
|
|
72
|
+
"""
|
|
73
|
+
if not starts_an_artifact_page(
|
|
74
|
+
all_hook_fields.get(TOOL_NAME_KEY), all_hook_fields.get(TOOL_INPUT_KEY)
|
|
75
|
+
):
|
|
76
|
+
return None
|
|
77
|
+
return {
|
|
78
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
79
|
+
HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
|
|
80
|
+
ADDITIONAL_CONTEXT_KEY: f"{POINTER_TEXT_PREFIX}{ARTIFACT_TEMPLATE_PATH}.",
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main() -> int:
|
|
86
|
+
"""Read the PreToolUse payload and print the pointer when the call starts a page.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
0 in every case; the pointer travels in the JSON output.
|
|
90
|
+
"""
|
|
91
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
92
|
+
if hook_payload is None:
|
|
93
|
+
return 0
|
|
94
|
+
hook_output = decide_hook_output(hook_payload)
|
|
95
|
+
if hook_output is not None:
|
|
96
|
+
sys.stdout.write(json.dumps(hook_output))
|
|
97
|
+
sys.stdout.flush()
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
sys.exit(main())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
7
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
8
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
9
|
+
if each_sys_path_entry not in sys.path:
|
|
10
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
11
|
+
|
|
12
|
+
import artifact_template_pointer
|
|
13
|
+
from hooks_constants.artifact_template_pointer_constants import ARTIFACT_TEMPLATE_PATH
|
|
14
|
+
|
|
15
|
+
HOOK_SCRIPT = Path(__file__).resolve().parent / "artifact_template_pointer.py"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _payload(tool_name: str, tool_input: object) -> dict[str, object]:
|
|
19
|
+
return {"hook_event_name": "PreToolUse", "tool_name": tool_name, "tool_input": tool_input}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run_hook(payload: dict[str, object]) -> str:
|
|
23
|
+
completed = subprocess.run(
|
|
24
|
+
[sys.executable, str(HOOK_SCRIPT)],
|
|
25
|
+
input=json.dumps(payload),
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
check=True,
|
|
29
|
+
)
|
|
30
|
+
return completed.stdout
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_artifact_quickstart_gets_the_template_pointer() -> None:
|
|
34
|
+
hook_output = json.loads(_run_hook(_payload("Artifact", {"action": "quickstart", "intent": "other"})))
|
|
35
|
+
specific_output = hook_output["hookSpecificOutput"]
|
|
36
|
+
assert specific_output["hookEventName"] == "PreToolUse"
|
|
37
|
+
assert str(ARTIFACT_TEMPLATE_PATH) in specific_output["additionalContext"]
|
|
38
|
+
assert "permissionDecision" not in specific_output
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_artifact_design_skill_gets_the_template_pointer() -> None:
|
|
42
|
+
hook_output = artifact_template_pointer.decide_hook_output(
|
|
43
|
+
_payload("Skill", {"skill": "artifact-design"})
|
|
44
|
+
)
|
|
45
|
+
assert hook_output is not None
|
|
46
|
+
assert str(ARTIFACT_TEMPLATE_PATH) in hook_output["hookSpecificOutput"]["additionalContext"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_artifact_publish_stays_quiet() -> None:
|
|
50
|
+
assert _run_hook(_payload("Artifact", {"action": "publish", "file_path": "page.html"})) == ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_other_skill_stays_quiet() -> None:
|
|
54
|
+
assert artifact_template_pointer.decide_hook_output(_payload("Skill", {"skill": "pr-lifecycle"})) is None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_tool_input_that_is_not_an_object_stays_quiet() -> None:
|
|
58
|
+
assert artifact_template_pointer.decide_hook_output(_payload("Artifact", "quickstart")) is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_empty_stdin_stays_quiet() -> None:
|
|
62
|
+
completed = subprocess.run(
|
|
63
|
+
[sys.executable, str(HOOK_SCRIPT)], input="", capture_output=True, text=True, check=True
|
|
64
|
+
)
|
|
65
|
+
assert completed.stdout == ""
|