sign-off-axi 0.4.8 → 0.5.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.
- package/README.md +71 -338
- package/dist/cli.js +105 -23
- package/dist/store.js +6 -2
- package/package.json +2 -1
- package/web/apple-touch-icon.png +0 -0
- package/web/icon-192.png +0 -0
- package/web/icon-512.png +0 -0
- package/web/icon-maskable-512.png +0 -0
- package/web/icon.svg +10 -0
- package/web/index.html +48 -23
- package/web/manifest.webmanifest +14 -0
package/README.md
CHANGED
|
@@ -1,380 +1,111 @@
|
|
|
1
|
-
<p align="center"><b>▶ <a href="https://umeranjum17.github.io/sign-off/">Try it live</a></b> — the real board on demo data: tap an answer and watch it save. No install, nothing leaves your browser.</p>
|
|
2
|
-
|
|
3
|
-
<p align="center">
|
|
4
|
-
<img src="docs/media/demo-tap.gif" width="300" alt="Tap Yes on a demo question, see it saved, and read the saved answer back">
|
|
5
|
-
</p>
|
|
6
|
-
|
|
7
1
|
<p align="center">
|
|
8
|
-
<
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-dark.svg">
|
|
4
|
+
<img src="docs/logo.svg" width="96" height="96" alt="sign-off logo: a white check mark on a blue tile">
|
|
5
|
+
</picture>
|
|
9
6
|
</p>
|
|
10
7
|
|
|
11
8
|
<h1 align="center">sign-off</h1>
|
|
12
9
|
|
|
13
|
-
<p align="center"><b>One calm page for every question the programs working for you need answered.</b><br>
|
|
10
|
+
<p align="center"><b>One calm page for every question the programs working for you need answered.</b><br>Agents ask; you answer with one tap on your own machine; the answer is saved for the asker to read. Proof of finished work lands on the same page, so you check it instead of taking anyone's word.</p>
|
|
11
|
+
|
|
12
|
+
<p align="center"><b>▶ <a href="https://umeranjum17.github.io/sign-off/demo/">Try it live</a></b>: the real board on demo data. No install, nothing leaves your browser. · <a href="https://umeranjum17.github.io/sign-off/">Website</a></p>
|
|
14
13
|
|
|
15
14
|
<p align="center">
|
|
16
15
|
<picture>
|
|
17
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/
|
|
18
|
-
<img src="docs/media/
|
|
16
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/media/desk-today-dark.webp">
|
|
17
|
+
<img src="docs/media/desk-today.webp" alt="The board at desk width: Today with what needs you, what was proven and a list of waiting asks">
|
|
19
18
|
</picture>
|
|
20
19
|
</p>
|
|
21
20
|
|
|
22
|
-
##
|
|
23
|
-
|
|
24
|
-
```sh
|
|
25
|
-
npm install -g sign-off-axi
|
|
26
|
-
sign-off-axi raise \
|
|
27
|
-
--title "Keep the weekly \
|
|
28
|
-
digest?" \
|
|
29
|
-
--why "The paid plan \
|
|
30
|
-
renews Monday." \
|
|
31
|
-
--outcome yes="It keeps \
|
|
32
|
-
going out." \
|
|
33
|
-
--outcome no="It stops \
|
|
34
|
-
this week." \
|
|
35
|
-
--recommend yes \
|
|
36
|
-
--recommend-reason "Readers \
|
|
37
|
-
still open it." \
|
|
38
|
-
--default yes
|
|
39
|
-
sign-off-axi open
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Open the printed URL, click **Yes**, then read the answer back the way an
|
|
43
|
-
agent would:
|
|
21
|
+
## Install in 3 steps
|
|
44
22
|
|
|
45
23
|
```sh
|
|
46
|
-
sign-off-axi
|
|
47
|
-
|
|
24
|
+
npm install -g sign-off-axi # 1. install from npm
|
|
25
|
+
npx -y sign-off-axi init # 2. in your project: set up .sign-off/ and the agent skill
|
|
26
|
+
sign-off-axi open # 3. start the board and open the printed link
|
|
48
27
|
```
|
|
49
28
|
|
|
50
|
-
|
|
51
|
-
Every question says why it is asked, what each answer does, which one the
|
|
52
|
-
agent recommends and why, and what happens if you stay quiet; a question
|
|
53
|
-
that does not is refused before it reaches you (see
|
|
54
|
-
[the question standard](docs/contract.md#ask-schema-1)). The
|
|
55
|
-
board is the default one (`~/.sign-off/decisions.json`); name a file after
|
|
56
|
-
any command to use another board.
|
|
57
|
-
|
|
58
|
-
## What you can ask
|
|
59
|
-
|
|
60
|
-
One line on what the agent runs, one screenshot of what you see. The full
|
|
61
|
-
reference for each kind lives in [docs/question-kinds.md](docs/question-kinds.md);
|
|
62
|
-
the message rules are in [docs/contract.md](docs/contract.md).
|
|
63
|
-
|
|
64
|
-
### Yes or no
|
|
29
|
+
Then ask your first question, the way an agent would, and answer it on the board:
|
|
65
30
|
|
|
66
31
|
```sh
|
|
67
32
|
sign-off-axi raise \
|
|
68
|
-
--title "Keep the weekly \
|
|
69
|
-
|
|
70
|
-
--
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
--why "The paid plan \
|
|
74
|
-
renews Monday." \
|
|
75
|
-
--outcome yes="It keeps \
|
|
76
|
-
going out each Monday." \
|
|
77
|
-
--outcome no="It stops \
|
|
78
|
-
after this week." \
|
|
79
|
-
--recommend yes \
|
|
80
|
-
--recommend-reason "Readers \
|
|
81
|
-
still open it." \
|
|
82
|
-
--default yes \
|
|
83
|
-
--silent-by 2026-10-19T09:00Z
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
<picture>
|
|
87
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-yes-no-dark.webp">
|
|
88
|
-
<img src="docs/media/kind-yes-no.webp" alt="A yes/no question with the recommended Yes marked">
|
|
89
|
-
</picture>
|
|
90
|
-
|
|
91
|
-
*A yes/no ask. The recommended answer is marked.*
|
|
92
|
-
|
|
93
|
-
### Pick one, with a recommendation
|
|
94
|
-
|
|
95
|
-
```sh
|
|
96
|
-
sign-off-axi raise \
|
|
97
|
-
--title "When should \
|
|
98
|
-
the new settings \
|
|
99
|
-
page go out?" \
|
|
100
|
-
--about "No new \
|
|
101
|
-
bug reports for three \
|
|
102
|
-
weeks." \
|
|
103
|
-
--why "Support is fully \
|
|
104
|
-
staffed on Friday." \
|
|
105
|
-
--option today=Today \
|
|
106
|
-
--option friday=Friday \
|
|
107
|
-
--option next-week="Next \
|
|
108
|
-
week" \
|
|
109
|
-
--outcome today="It goes \
|
|
110
|
-
out now." \
|
|
111
|
-
--outcome friday="It goes \
|
|
112
|
-
out with support on hand." \
|
|
113
|
-
--outcome next-week="It \
|
|
114
|
-
waits a week." \
|
|
115
|
-
--recommend friday \
|
|
116
|
-
--recommend-reason "Support \
|
|
117
|
-
can answer questions." \
|
|
118
|
-
--default next-week
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
<picture>
|
|
122
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-pick-one-dark.webp">
|
|
123
|
-
<img src="docs/media/kind-pick-one.webp" alt="A pick-one question with three options and Friday recommended">
|
|
124
|
-
</picture>
|
|
125
|
-
|
|
126
|
-
*Pick one of three. Friday is recommended; silence means next week.*
|
|
127
|
-
|
|
128
|
-
### Design pick, with mocks A/B/C
|
|
129
|
-
|
|
130
|
-
```sh
|
|
131
|
-
sign-off-axi post \
|
|
132
|
-
--type design \
|
|
133
|
-
--title "Pick the settings \
|
|
134
|
-
layout" \
|
|
135
|
-
--option a="Layout A" \
|
|
136
|
-
--option b="Layout B" \
|
|
137
|
-
--option c="Layout C" \
|
|
138
|
-
--mock a=mocks/a.html \
|
|
139
|
-
--mock b=mocks/b.html \
|
|
140
|
-
--mock c=mocks/c.html \
|
|
141
|
-
--tradeoff "A is fastest \
|
|
142
|
-
to tap; B shows both \
|
|
143
|
-
choices; C fits small \
|
|
144
|
-
screens." \
|
|
145
|
-
--recommend b
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
<picture>
|
|
149
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-design-dark.webp">
|
|
150
|
-
<img src="docs/media/kind-design.webp" alt="Three mock-ups side by side in phone frames with Pick buttons, Layout B recommended">
|
|
151
|
-
</picture>
|
|
152
|
-
|
|
153
|
-
*Three layouts side by side. Layout B is recommended.*
|
|
154
|
-
|
|
155
|
-
### Proof update, with Pass / Fail / Needs work
|
|
156
|
-
|
|
157
|
-
```sh
|
|
158
|
-
sign-off-axi post \
|
|
159
|
-
--type evidence \
|
|
160
|
-
--title "Settings page \
|
|
161
|
-
loads in a second" \
|
|
162
|
-
--about "Umer opens \
|
|
163
|
-
the new page on a phone." \
|
|
164
|
-
--media shot-light.png \
|
|
165
|
-
--media shot-dark.png
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
<picture>
|
|
169
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-evidence-dark.webp">
|
|
170
|
-
<img src="docs/media/kind-evidence.webp" alt="Evidence update 1 with two captures and Pass, Fail, Needs work buttons">
|
|
171
|
-
</picture>
|
|
172
|
-
|
|
173
|
-
*Proof of a fix, with two captures. Pass, Fail, or Needs work.*
|
|
174
|
-
|
|
175
|
-
Evidence lists passed proof only: an update whose linked run passed. A plain update like this links no run, so it waits in **Waiting on you** instead, as does a failed or unchecked run, each with its honest result and its verdicts and Dismiss, so the agent still gets an answer. Passed updates are grouped by the project named in the run's proof line, `verify-<project>: ...` (trimmed, compared without case); a proof line with no such prefix goes under **Other**.
|
|
176
|
-
|
|
177
|
-
### Explainer video, with chapters
|
|
178
|
-
|
|
179
|
-
```sh
|
|
180
|
-
sign-off-axi post \
|
|
181
|
-
--type explainer \
|
|
182
|
-
--title "Is the new \
|
|
183
|
-
sign-in clear?" \
|
|
184
|
-
--about "Two steps: \
|
|
185
|
-
password, then a code." \
|
|
186
|
-
--media explainer.mp4 \
|
|
187
|
-
--chapter 0:00=Start \
|
|
188
|
-
--chapter 0:02="The tap"
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
<picture>
|
|
192
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-explainer-dark.webp">
|
|
193
|
-
<img src="docs/media/kind-explainer.webp" alt="An explainer card with a video player, chapter buttons and Yes/No">
|
|
194
|
-
</picture>
|
|
195
|
-
|
|
196
|
-
*A short video with chapters, then Yes or No.*
|
|
197
|
-
|
|
198
|
-
### Explainer page, to read
|
|
199
|
-
|
|
200
|
-
```sh
|
|
201
|
-
sign-off-axi report init \
|
|
202
|
-
.sign-off/mocks/sign-in
|
|
203
|
-
d=mocks/sign-in
|
|
204
|
-
sign-off-axi post \
|
|
205
|
-
--type report \
|
|
206
|
-
--title "Sign-in now \
|
|
207
|
-
takes 2 steps" \
|
|
208
|
-
--about "Why the code \
|
|
209
|
-
step moved first." \
|
|
210
|
-
--view $d/index.html \
|
|
211
|
-
--preview $d/preview.svg
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
<picture>
|
|
215
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-report-dark.webp">
|
|
216
|
-
<img src="docs/media/kind-report.webp" alt="An explainer page: the answer in one sentence, a diagram, steps to tap through, folded details">
|
|
217
|
-
</picture>
|
|
218
|
-
|
|
219
|
-
*An explainer page to tap through and read.*
|
|
220
|
-
|
|
221
|
-
### Diagram
|
|
222
|
-
|
|
223
|
-
```sh
|
|
224
|
-
sign-off-axi post \
|
|
225
|
-
--type diagram \
|
|
226
|
-
--title "Is this the \
|
|
227
|
-
sign-in flow?" \
|
|
228
|
-
--about "Password first, \
|
|
229
|
-
then a code, then in." \
|
|
230
|
-
--view flow.svg \
|
|
231
|
-
--recommend yes
|
|
33
|
+
--title "Keep the weekly digest?" \
|
|
34
|
+
--why "The paid plan renews Monday." \
|
|
35
|
+
--outcome yes="It keeps going out." --outcome no="It stops this week." \
|
|
36
|
+
--recommend yes --recommend-reason "Readers still open it." --default yes
|
|
37
|
+
sign-off-axi answers --key keep-the-weekly-digest # read the tap back
|
|
232
38
|
```
|
|
233
39
|
|
|
234
|
-
|
|
235
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-diagram-dark.webp">
|
|
236
|
-
<img src="docs/media/kind-diagram.webp" alt="A question shown with a three-box flow diagram at card width">
|
|
237
|
-
</picture>
|
|
40
|
+
You need Node 22.5 or newer. `init` installs the skill for each coding agent it finds on your `PATH`; `sign-off-axi --help` lists every command, and every command takes `--help`.
|
|
238
41
|
|
|
239
|
-
|
|
42
|
+
## The board
|
|
240
43
|
|
|
241
|
-
|
|
44
|
+
Four places, in the bar at the bottom of a phone and the sidebar on a desk.
|
|
242
45
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
--option yes=Yes \
|
|
252
|
-
--option no="Needs \
|
|
253
|
-
work" \
|
|
254
|
-
--recommend yes
|
|
255
|
-
```
|
|
46
|
+
<table>
|
|
47
|
+
<tr>
|
|
48
|
+
<td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-today-dark.webp"><img src="docs/media/phone-today.webp" alt="Today: counts of what needs you, what was proven and new reports, then the waiting asks"></picture><br><b>Today</b><br>What needs you, what was proven, what is new.</td>
|
|
49
|
+
<td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-decide-dark.webp"><img src="docs/media/phone-decide.webp" alt="Decide: one card per ask that waits on you, filtered by type"></picture><br><b>Decide</b><br>Every ask that waits on you, one tap each.</td>
|
|
50
|
+
<td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-evidence-dark.webp"><img src="docs/media/phone-evidence.webp" alt="Evidence: a capture of the finished work with its result, grouped by project"></picture><br><b>Evidence</b><br>Proof that a change works, with its honest result.</td>
|
|
51
|
+
<td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-reports-dark.webp"><img src="docs/media/phone-reports.webp" alt="Reports: explainer pages you asked for"></picture><br><b>Reports</b><br>Explainers you asked for, to read.</td>
|
|
52
|
+
</tr>
|
|
53
|
+
</table>
|
|
256
54
|
|
|
257
|
-
|
|
258
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-interactive-dark.webp">
|
|
259
|
-
<img src="docs/media/kind-interactive.webp" alt="A question with a sandboxed counter page to tap">
|
|
260
|
-
</picture>
|
|
55
|
+
### Evidence is not a decision
|
|
261
56
|
|
|
262
|
-
|
|
57
|
+
**Decide** holds only real asks: things an agent cannot do without your answer. **Evidence** holds proof (screenshots or a short recording of the running product) with the result of the check that made it: Passed, Not run, Failed, Did not finish or Could not check. A proof update is never counted as something that needs you; you can give it a verdict (Pass, Fail or Needs work) or dismiss it. **Today** counts the two separately: what needs you, and what was proven today.
|
|
263
58
|
|
|
264
|
-
###
|
|
59
|
+
### Plans and reports, on their own pages
|
|
265
60
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
the release" \
|
|
274
|
-
--waits "Umer" \
|
|
275
|
-
--expires 15m \
|
|
276
|
-
--key npm-login
|
|
277
|
-
```
|
|
61
|
+
<table>
|
|
62
|
+
<tr>
|
|
63
|
+
<td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-plan-dark.webp"><img src="docs/media/phone-plan.webp" alt="A plan page: the idea in one picture, then each section to approve or change"></picture><br><b>Plan</b><br>Approve or change each section, answer each decision, send it all back at once.</td>
|
|
64
|
+
<td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-report-dark.webp"><img src="docs/media/phone-report.webp" alt="A report page: the answer first, then a diagram to tap through"></picture><br><b>Report</b><br>An explainer page: the answer first, then a diagram and steps to tap through.</td>
|
|
65
|
+
<td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-signin-dark.webp"><img src="docs/media/phone-signin.webp" alt="A sign-in card: the service, what it unlocks, who waits and the time left"></picture><br><b>Sign-in</b><br>An agent needs you to log in: tap Open, then it carries on.</td>
|
|
66
|
+
</tr>
|
|
67
|
+
</table>
|
|
278
68
|
|
|
279
|
-
|
|
69
|
+
A plan is what an agent wants to do before it starts. A report is an explanation you asked for. Both open over the board, and the board, not the agent's page, owns the answer buttons. A **sign-in card** goes to the top of Decide with the service, what it unlocks, who is waiting and the time left on the link; the agent closes it with `sign-off-axi signin done`.
|
|
280
70
|
|
|
281
|
-
|
|
282
|
-
sign-off-axi signin done \
|
|
283
|
-
--key npm-login
|
|
284
|
-
```
|
|
71
|
+
### It keeps only what matters
|
|
285
72
|
|
|
286
|
-
|
|
287
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-signin-dark.webp">
|
|
288
|
-
<img src="docs/media/kind-signin.webp" alt="A sign-in card on top with an Open button, what it unlocks, who waits and the time left">
|
|
289
|
-
</picture>
|
|
290
|
-
|
|
291
|
-
*A sign-in card: what it unlocks, who waits, the time left.*
|
|
292
|
-
|
|
293
|
-
### Release gate
|
|
294
|
-
|
|
295
|
-
```sh
|
|
296
|
-
sign-off-axi post \
|
|
297
|
-
--kind gate \
|
|
298
|
-
--from gate.json
|
|
299
|
-
```
|
|
73
|
+
A board that fills up with noise stops being read, so the rules sit at the door.
|
|
300
74
|
|
|
301
|
-
A
|
|
75
|
+
- A question must say why it is asked, what each answer does, which one the agent recommends and what happens if you stay quiet; one that does not is refused with the fix ([the question standard](docs/contract.md#ask-schema-1)).
|
|
76
|
+
- One decision is one card: raising the same question again changes nothing, and `--merge <key>` folds an older card into the better-asked one.
|
|
77
|
+
- Proof posted as a Pass/Fail question is refused, and proof that is mostly text, a placeholder or a test card is refused too: proof goes to Evidence, as a capture of the real product; a report must be a page to tap, not a wall of words.
|
|
78
|
+
- **Dismiss** hides a card from the board, **Undo** brings it back (`sign-off-axi dismiss <key>` and `restore <key>` do the same from a shell).
|
|
302
79
|
|
|
303
|
-
|
|
304
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-gate-dark.webp">
|
|
305
|
-
<img src="docs/media/kind-gate.webp" alt="A gate question with Approve recommended and Fix first">
|
|
306
|
-
</picture>
|
|
307
|
-
|
|
308
|
-
*A held run: Approve it or Fix first.*
|
|
309
|
-
|
|
310
|
-
### Retro finding
|
|
311
|
-
|
|
312
|
-
```sh
|
|
313
|
-
sign-off-axi post \
|
|
314
|
-
--kind finding \
|
|
315
|
-
--from finding.json
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
A `finding.json` asks the user to Apply or Skip a proposed fix.
|
|
319
|
-
|
|
320
|
-
<picture>
|
|
321
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-finding-dark.webp">
|
|
322
|
-
<img src="docs/media/kind-finding.webp" alt="A finding question with Apply recommended and Skip">
|
|
323
|
-
</picture>
|
|
324
|
-
|
|
325
|
-
*A retro fix to Apply or Skip.*
|
|
326
|
-
|
|
327
|
-
## On your phone
|
|
328
|
-
|
|
329
|
-
The same board on a phone, light and dark.
|
|
330
|
-
|
|
331
|
-
<picture>
|
|
332
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-dark.webp">
|
|
333
|
-
<img src="docs/media/phone-light.webp" width="320" alt="Board summary on a phone in light mode">
|
|
334
|
-
</picture>
|
|
335
|
-
|
|
336
|
-
*The summary, the waiting list, and the answers, in a phone frame.*
|
|
337
|
-
|
|
338
|
-
## On your screen
|
|
339
|
-
|
|
340
|
-
The waiting list and the summary at desk width, light and dark.
|
|
80
|
+
## What you can ask
|
|
341
81
|
|
|
342
|
-
|
|
343
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/media/desktop-dark.webp">
|
|
344
|
-
<img src="docs/media/desktop-light.webp" alt="Board summary at desk width in light mode">
|
|
345
|
-
</picture>
|
|
82
|
+
One line on what the agent runs per kind; the full reference is [docs/question-kinds.md](docs/question-kinds.md) and the message rules are in [docs/contract.md](docs/contract.md).
|
|
346
83
|
|
|
347
|
-
|
|
84
|
+
| Kind | The agent runs | You see |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| Yes or no, pick one | `raise --title ... --recommend ...` | buttons, the recommended one first |
|
|
87
|
+
| Design pick (mocks A/B/C) | `post --type design --mock a=... --mock b=... --link a=<url>` | mocks side by side, an Open live link under each option that has a `--link`, a note on any part |
|
|
88
|
+
| Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
|
|
89
|
+
| Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
|
|
90
|
+
| Report page | `report init <folder>`, then `post --type report --view ...` | a page to tap through |
|
|
91
|
+
| Diagram, interactive page | `post --type diagram --view flow.svg` | the picture on the card; interactive pages run sandboxed |
|
|
92
|
+
| Plan | `post --kind plan --from plan.json` | sections to approve or change, decisions to answer |
|
|
93
|
+
| Sign-in link | `post --type signin --service npm --link <url> --unlocks ...` | an Open button and the time left |
|
|
348
94
|
|
|
349
95
|
## How agents use it
|
|
350
96
|
|
|
351
|
-
Every item records who filed it, and
|
|
352
|
-
event, by itself: the agent raises with `--filer my-agent` and blocks on
|
|
353
|
-
`sign-off-axi wait --filer my-agent`, which returns only that filer's
|
|
354
|
-
answers, each once, and the card shows **Seen by agent**. An agent that
|
|
355
|
-
cannot block can be woken instead: an `on_answer:` line in the
|
|
356
|
-
`config.yaml` beside the board names a command the board runs for every
|
|
357
|
-
answer. A `notify:` block in that config can send an HTTP POST when a card
|
|
358
|
-
enters Waiting on you, with its bearer token read from a named environment
|
|
359
|
-
variable, or, with `receiver: muxr`, from the muxr host's `notify-token`
|
|
360
|
-
file; `sign-off-axi notify test` sends a test notice. The full reference, with the worked example, is in
|
|
361
|
-
[docs/commands.md](docs/commands.md).
|
|
362
|
-
|
|
363
|
-
Every command takes `--help` and prints compact output with next-step hints.
|
|
364
|
-
sign-off itself calls no model and inherits the calling agent's model routing.
|
|
97
|
+
Every item records who filed it. The agent raises with `--filer my-agent`, then blocks on `sign-off-axi wait --filer my-agent`, which returns that filer's answers, each once, and the card shows **Seen by agent**. An agent that cannot block can be woken instead: an `on_answer:` line in the `config.yaml` beside the board names a command the board runs for every answer, and a `notify:` block can send an HTTP POST when a card enters Decide. The worked examples are in [docs/commands.md](docs/commands.md).
|
|
365
98
|
|
|
366
|
-
|
|
99
|
+
sign-off itself calls no model and inherits the calling agent's model routing. The shipped skill is deliberately thin: it tells the agent how to write a good card and defers to `sign-off-axi --help` for every flag, so an installed copy never goes stale.
|
|
367
100
|
|
|
368
|
-
|
|
369
|
-
npm install -g sign-off-axi
|
|
370
|
-
```
|
|
101
|
+
## Works alone, or with its siblings
|
|
371
102
|
|
|
372
|
-
|
|
373
|
-
skill for each coding agent on your `PATH`:
|
|
103
|
+
sign-off stands alone: the CLI and the skill need no other tool and no supervisor. They connect through one small written contract ([docs/contract.md](docs/contract.md)):
|
|
374
104
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
105
|
+
- **done-done** (verification) posts its proof as Evidence and holds a run for your Approve or Fix first as a gate.
|
|
106
|
+
- **learn-from-mistakes** (retros) posts each proposed change as a finding for you to Apply or Skip.
|
|
107
|
+
- **first-draft** (design) writes its review links into a design card, shown as real links to open the mock-up.
|
|
108
|
+
- **An agent supervisor**, such as Firstmate, or a lone agent in a terminal: both post the same asks, plans and reports and read the same answers.
|
|
378
109
|
|
|
379
110
|
## Docs and reference
|
|
380
111
|
|
|
@@ -395,12 +126,14 @@ npm pack
|
|
|
395
126
|
```
|
|
396
127
|
|
|
397
128
|
- `npm test`: full journeys
|
|
398
|
-
- `npm run build:skill`: skill
|
|
399
|
-
- `npm run build:demo`:
|
|
129
|
+
- `npm run build:skill`: writes the skill from `src/skill.ts`
|
|
130
|
+
- `npm run build:demo`: the website (`site/`) and the demo board at `/demo/`, built into `demo-dist/`
|
|
400
131
|
- `npm pack`: tarball
|
|
401
132
|
|
|
402
133
|
A release is a merged version-bump PR: CI publishes the new version to npm.
|
|
403
134
|
|
|
135
|
+
Change what a user sees or runs, and update this README, the skill (`src/skill.ts`), the website (`site/index.html`) and any screenshot they show in the same PR.
|
|
136
|
+
|
|
404
137
|
### Test runs never touch your board
|
|
405
138
|
|
|
406
139
|
With no board named, a bare command uses the nearest `.sign-off/` that
|
package/dist/cli.js
CHANGED
|
@@ -28,8 +28,8 @@ const AGENTS = {
|
|
|
28
28
|
'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
|
|
29
29
|
};
|
|
30
30
|
const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
|
|
31
|
-
commands[
|
|
32
|
-
(none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, doctor, notify, validate, help
|
|
31
|
+
commands[21]:
|
|
32
|
+
(none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, dismiss, restore, refile, doctor, notify, validate, help
|
|
33
33
|
board: the nearest .sign-off/ board (the walk's bounds and SIGN_OFF_HOME: README, "Test runs never touch your board"), else ~/.sign-off/decisions.json; a board file given always wins
|
|
34
34
|
flags: --full and --json on list, answers and wait, --events on wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
|
|
35
35
|
examples:
|
|
@@ -43,6 +43,7 @@ examples:
|
|
|
43
43
|
sign-off-axi post .sign-off/decisions.json --type report --title "Sign-in now takes 2 steps" --about "Why the code step moved first." --view mocks/sign-in-flow/index.html --preview mocks/sign-in-flow/preview.svg
|
|
44
44
|
sign-off-axi post --type signin --key npm-login --service npm --link https://example.com/login --unlocks "Publish the release" --waits "Main" --expires 15m
|
|
45
45
|
sign-off-axi signin done --key npm-login
|
|
46
|
+
sign-off-axi dismiss ship-the-new-sign-in-screen-this-week
|
|
46
47
|
sign-off-axi init
|
|
47
48
|
sign-off-axi doctor
|
|
48
49
|
sign-off-axi validate ask.json
|
|
@@ -175,7 +176,7 @@ Runs the board in the foreground until Ctrl-C; --port 0 picks a free port, and i
|
|
|
175
176
|
examples:
|
|
176
177
|
sign-off-axi serve --port 0
|
|
177
178
|
`,
|
|
178
|
-
post: `usage: sign-off-axi post [board.json] [--type decision|design|diagram|interactive] --title "<question>" [--key <key>] [--about <text>] [--option <value>=<label>]... [--mock <value>=<path>]... [--view <path>] [--tradeoff <text>] [--recommend <value>] [--default <value>] [--close done|release] [--filer <id>]
|
|
179
|
+
post: `usage: sign-off-axi post [board.json] [--type decision|design|diagram|interactive] --title "<question>" [--key <key>] [--about <text>] [--option <value>=<label>]... [--mock <value>=<path>]... [--link <value>=<url>]... [--view <path>] [--tradeoff <text>] [--recommend <value>] [--default <value>] [--close done|release] [--filer <id>]
|
|
179
180
|
sign-off-axi post [board.json] --type evidence --title "<what this shows>" --about "<short note>" --media <file>... [--link <commit or URL>] [--detail <text>] [--filer <id>]
|
|
180
181
|
sign-off-axi post [board.json] --from <board file> --replace [--filer <id>]
|
|
181
182
|
sign-off-axi post [board.json] --kind <kind> --from <message.json|-> [--filer <id>]
|
|
@@ -196,6 +197,7 @@ Listing --option needs --recommend <value>, on every type: the option you would
|
|
|
196
197
|
SIGN_OFF_FILER sets it for every command; with neither, the Claude Code session id is saved when there is one.
|
|
197
198
|
--type design is a pick between up to 6 options shown side by side, each with an optional board-relative mock file
|
|
198
199
|
(.html, .svg, .png, .jpg, .jpeg, .webp, 5 MB or less), a --tradeoff line and a --recommend badge.
|
|
200
|
+
One --link <value>=<url> per option (http or https) draws an Open link under that option's mock, outside its sandboxed frame; the option needs a --mock.
|
|
199
201
|
One .html page can hold every variant: --mock a=mocks/<key>/index.html?variant=a and so on; the page reads ?variant= to show one.
|
|
200
202
|
An .html mock or view loads the css, scripts, images and fonts beside it by relative name, from its own folder only.
|
|
201
203
|
After the user picks, answers --key <key> --json holds the pick and note; "rounds" holds an ask for another round.
|
|
@@ -217,8 +219,9 @@ and played on the card. --about is its short summary; up to ${CHAPTERS_MAX} --ch
|
|
|
217
219
|
--view is the page and --preview its picture on the card, beside it, both from the board file's folder; \`sign-off-axi report init <folder>\` makes them.
|
|
218
220
|
The page must be filled (no FILL: line left), with a viewport tag, dark mode, a picture, something to tap, its detail folded, nothing from the web, and no block over about five lines on a phone.
|
|
219
221
|
Merges, PR outcomes, receipts and status notes are not reports: post proof of a change as evidence.
|
|
222
|
+
A proof is never an ask: a new ask that offers Pass and Fail is refused. sign-off-axi refile <key> --media <file> re-files an old one as evidence.
|
|
220
223
|
--type signin asks the user to sign in, at the top of what waits: the service, one Open button for the http(s) --link, what it unlocks,
|
|
221
|
-
who waits, and the time left until --expires (an ISO date-time, or 90s, 15m, 2h, 1d from now). After that time the card
|
|
224
|
+
who waits, and the time left until --expires (an ISO date-time, or 90s, 15m, 2h, 1d from now). After that time the card leaves the board on its own.
|
|
222
225
|
No output prints the link. Post again with the same --key and a new --link or --expires to put a new link on the same card; it waits again.
|
|
223
226
|
When the sign-in is done, run \`sign-off-axi signin done [board.json] --key <key>\`: the card leaves the open list and shows as done.
|
|
224
227
|
For a link that expires fast, give --start, the command that prints a new sign-in link, as a JSON list such as '["npm", "login", "--auth-type=web"]'.
|
|
@@ -248,6 +251,21 @@ Closes a sign-in card that post --type signin made: the sign-in is done, so the
|
|
|
248
251
|
Run it when you see the sign-in worked. A card posted with --start also closes by itself when its command ends with exit 0. wait --key <key> also returns once it is done.
|
|
249
252
|
examples:
|
|
250
253
|
sign-off-axi signin done --key npm-login
|
|
254
|
+
`,
|
|
255
|
+
dismiss: `usage: sign-off-axi dismiss <key> [board.json]
|
|
256
|
+
Hides a card from the board, like its Dismiss button. The agent is not told, and an ask it waits on keeps waiting.
|
|
257
|
+
restore <key> [board.json] brings it back. Find the key with \`sign-off-axi list --full\`.
|
|
258
|
+
examples:
|
|
259
|
+
sign-off-axi dismiss ship-the-new-sign-in-screen-this-week
|
|
260
|
+
sign-off-axi restore ship-the-new-sign-in-screen-this-week
|
|
261
|
+
`,
|
|
262
|
+
refile: `usage: sign-off-axi refile <key> [board.json] --media <file>... [--link <commit or URL>]
|
|
263
|
+
Re-files a proof that was posted as a Pass/Fail ask as an evidence update, which never waits on the user: posts --type evidence with the ask's title and note and your capture, then dismisses the ask.
|
|
264
|
+
examples:
|
|
265
|
+
sign-off-axi refile sign-in-looks-right --media sign-in.png --link https://github.com/acme/app/pull/12
|
|
266
|
+
`,
|
|
267
|
+
restore: `usage: sign-off-axi restore <key> [board.json]
|
|
268
|
+
Brings back a card that dismiss hid. See \`sign-off-axi dismiss --help\`.
|
|
251
269
|
`,
|
|
252
270
|
report: `usage: sign-off-axi report init <folder>
|
|
253
271
|
Writes a report page to fill: <folder>/index.html and its card picture <folder>/preview.svg. A file already there is kept.
|
|
@@ -502,7 +520,7 @@ function filerOf(values, session = false) {
|
|
|
502
520
|
const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
|
|
503
521
|
: `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
|
|
504
522
|
/** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
|
|
505
|
-
const waits = (d) => !d.dismissed && awaitsVerdict(d.type, d.answer) && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
|
|
523
|
+
const waits = (d) => d.type !== 'evidence' && !d.dismissed && awaitsVerdict(d.type, d.answer) && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
|
|
506
524
|
/** The board's decisions with their latest answer; a missing board file is an empty board.
|
|
507
525
|
* A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
|
|
508
526
|
function board(file, say = warn) {
|
|
@@ -543,7 +561,7 @@ function list(args) {
|
|
|
543
561
|
];
|
|
544
562
|
const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
|
|
545
563
|
if (values.full) {
|
|
546
|
-
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
|
|
564
|
+
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : d.type === 'evidence' ? 'shown' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
|
|
547
565
|
recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
|
|
548
566
|
}
|
|
549
567
|
return toon({
|
|
@@ -797,11 +815,15 @@ const mockFile = (path) => path.split('?')[0];
|
|
|
797
815
|
/** Posts an item. `raise` is the alias for `post --type decision`: same flags as before, same output. */
|
|
798
816
|
function post(args, cmd = 'post') {
|
|
799
817
|
const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
|
|
800
|
-
media: { type: 'string', multiple: true }, link: { type: 'string' }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
|
|
818
|
+
media: { type: 'string', multiple: true }, link: { type: 'string', multiple: true }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
|
|
801
819
|
...Object.fromEntries(SIGNIN_FIELDS.map((f) => [f, { type: 'string' }])) } : {};
|
|
802
820
|
const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, ...typed,
|
|
803
821
|
...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: MANY.includes(f) }])) });
|
|
804
822
|
const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
|
|
823
|
+
// Only a design takes --link more than once (one per option); any other type keeps its one item link, the last given.
|
|
824
|
+
const optionLinks = values.link ?? [];
|
|
825
|
+
if (values.type !== 'design' && optionLinks.length)
|
|
826
|
+
values.link = optionLinks.at(-1);
|
|
805
827
|
if (values.kind !== undefined) {
|
|
806
828
|
const odd = [...given, ...(values.replace ? ['replace'] : [])][0];
|
|
807
829
|
if (odd)
|
|
@@ -833,8 +855,8 @@ function post(args, cmd = 'post') {
|
|
|
833
855
|
throw fail(`--type must be decision, design, evidence, diagram, interactive, explainer, report or signin`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
834
856
|
if (type === 'evidence' || type === 'explainer')
|
|
835
857
|
return postMedia(file, named, on, values, type, filer);
|
|
836
|
-
if (values.media !== undefined || values.link !== undefined || values.chapter !== undefined)
|
|
837
|
-
throw fail('--media, --link and --chapter need --type evidence or explainer', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
858
|
+
if (values.media !== undefined || (type !== 'design' && values.link !== undefined) || values.chapter !== undefined)
|
|
859
|
+
throw fail('--media, --link and --chapter need --type evidence or explainer (--link also goes on --type design, one per option)', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
838
860
|
if (type !== 'design' && (values.tradeoff !== undefined || values.mock !== undefined))
|
|
839
861
|
throw fail('--tradeoff and --mock need --type design', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
840
862
|
const view = type === 'diagram' || type === 'interactive';
|
|
@@ -886,8 +908,12 @@ function post(args, cmd = 'post') {
|
|
|
886
908
|
if (info && (!info.isFile() || info.size > MOCK_BYTES)) {
|
|
887
909
|
throw fail(`--mock ${path} must be a file of 5 MB or less`, 'VALIDATION_ERROR', 'Shrink the mock, or point --mock at the screenshot of the variant');
|
|
888
910
|
}
|
|
889
|
-
|
|
911
|
+
const value = m.slice(0, at), link = optionLinks.find((l) => l.startsWith(`${value}=`));
|
|
912
|
+
return { value, path, ...(link && { link: link.slice(value.length + 1) }) };
|
|
890
913
|
});
|
|
914
|
+
const lost = optionLinks.find((l) => !mocks?.some((m) => l.startsWith(`${m.value}=`)));
|
|
915
|
+
if (type === 'design' && lost !== undefined)
|
|
916
|
+
throw fail(`--link ${lost} must be <value>=<url> with a --mock for that value`, 'VALIDATION_ERROR', 'sign-off-axi post --type design --title "<question>" --option a=A --option b=B --mock a=mocks/a.html --link a=https://example.com/a --recommend a');
|
|
891
917
|
return storeIncoming(file, named, on, buildIncoming(values, { type, tradeoff: values.tradeoff, mocks }, filer, file), filer);
|
|
892
918
|
}
|
|
893
919
|
const SIGNIN_FIELDS = ['service', 'unlocks', 'waits', 'expires', 'start'];
|
|
@@ -972,6 +998,37 @@ function signin(args) {
|
|
|
972
998
|
return toon({ board: tilde(file), closed: [{ key: d.key, status: saved ? 'done' : 'already done', service: d.title }] }, [`Run \`sign-off-axi list${on}\` to see what still waits on the user`]);
|
|
973
999
|
});
|
|
974
1000
|
}
|
|
1001
|
+
/** dismiss|restore <key> [board.json]: the board's Dismiss and Undo, for an agent. The same dismissed log the board writes, for a key that is on the board. */
|
|
1002
|
+
function dismiss(restoring) {
|
|
1003
|
+
const cmd = restoring ? 'restore' : 'dismiss';
|
|
1004
|
+
return (args) => {
|
|
1005
|
+
const at = args.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = args[at];
|
|
1006
|
+
if (at < 0)
|
|
1007
|
+
throw fail(`${cmd} needs a card key`, 'VALIDATION_ERROR', `sign-off-axi ${cmd} <key> [board.json]`);
|
|
1008
|
+
const { file, on } = parse(cmd, args.filter((_, i) => i !== at), {});
|
|
1009
|
+
return locked(`${file}.lock`, () => {
|
|
1010
|
+
if (!isKey(key) || !board(file).some((x) => x.key === key))
|
|
1011
|
+
throw fail(`no card with key ${key} on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi list${on} --full\` to see the keys`);
|
|
1012
|
+
appendDismissed(file, [key], !restoring);
|
|
1013
|
+
return toon({ board: tilde(file), [restoring ? 'restored' : 'dismissed']: [key] }, [`Run \`sign-off-axi ${restoring ? 'dismiss' : 'restore'}${on} ${key}\` to undo`]);
|
|
1014
|
+
});
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
/** refile <key> [board.json] --media <file>...: an old proof posted as a Pass/Fail ask becomes an evidence update (title and note kept), and the ask is dismissed. */
|
|
1018
|
+
async function refile(args) {
|
|
1019
|
+
const at = args.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = args[at];
|
|
1020
|
+
if (at < 0)
|
|
1021
|
+
throw fail('refile needs a card key', 'VALIDATION_ERROR', 'sign-off-axi refile <key> --media shot.png');
|
|
1022
|
+
const { file, named, on, values } = parse('refile', args.filter((_, i) => i !== at), { media: { type: 'string', multiple: true }, link: { type: 'string' }, filer: { type: 'string' } });
|
|
1023
|
+
const old = board(file).find((x) => x.key === key);
|
|
1024
|
+
if (!old)
|
|
1025
|
+
throw fail(`no card with key ${key} on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi list${on} --full\` to see the keys`);
|
|
1026
|
+
if (!waits(old) || !['pass', 'fail'].every((v) => old.options.some((o) => o.value === v)))
|
|
1027
|
+
throw fail(`${key} is not a waiting Pass/Fail ask`, 'VALIDATION_ERROR', 'refile takes a proof still waiting on the user as a Pass/Fail ask');
|
|
1028
|
+
const posted = await postMedia(file, named, on, { title: old.title, about: old.about?.trim() || old.title, media: values.media, link: values.link ?? old.link }, 'evidence', filerOf(values, true));
|
|
1029
|
+
locked(`${file}.lock`, () => appendDismissed(file, [key], true));
|
|
1030
|
+
return posted;
|
|
1031
|
+
}
|
|
975
1032
|
/** For a program that feeds the board: the --from file, checked like any post, becomes the whole board, items and top-level fields. Answers stay.
|
|
976
1033
|
* With a filer it replaces only that filer's items (and items with no filer it names by key); every other item and top-level field stays. */
|
|
977
1034
|
function replace(file, named, on, from, filer) {
|
|
@@ -1101,7 +1158,7 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1101
1158
|
if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
|
|
1102
1159
|
throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
|
|
1103
1160
|
}
|
|
1104
|
-
return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`]);
|
|
1161
|
+
return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, explainer ? [`Run \`sign-off-axi open${on}\` so the user can give a verdict`] : []);
|
|
1105
1162
|
}
|
|
1106
1163
|
const answers = readAnswers(file);
|
|
1107
1164
|
if (message && own && Object.hasOwn(answers, own) && !awaitsVerdict(message.kind, answers[own]))
|
|
@@ -1157,7 +1214,7 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1157
1214
|
notifyEntered(file, named, before);
|
|
1158
1215
|
if (explainer)
|
|
1159
1216
|
return toon({ board: tilde(file), raised: [{ key, status: 'raised', title }] }, [`Run \`sign-off-axi open${on}\` so the user can watch and answer`, waitFor(on, filer, key)]);
|
|
1160
|
-
return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] }
|
|
1217
|
+
return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] });
|
|
1161
1218
|
});
|
|
1162
1219
|
}
|
|
1163
1220
|
/** post --type report: the flags as one report message, posted as post --kind report posts it. A report with no page is refused before anything is read. */
|
|
@@ -1379,6 +1436,10 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
|
|
|
1379
1436
|
return locked(`${file}.lock`, () => {
|
|
1380
1437
|
const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
|
|
1381
1438
|
checkMerges(incoming, [...current.filter((c) => !incoming.some((d) => d.key === c.key)), ...incoming], current);
|
|
1439
|
+
// Proof is evidence: a new ask that offers Pass and Fail would sit in Waiting on you as an ask, so it is refused.
|
|
1440
|
+
const proof = incoming.find((d) => d.type === 'decision' && !d.kind && !current.some((c) => c.key === d.key) && ['pass', 'fail'].every((v) => d.options.some((o) => o.value === v)));
|
|
1441
|
+
if (proof)
|
|
1442
|
+
throw fail(`key ${proof.key} offers Pass and Fail: a proof is not an ask and would wait on you`, 'VALIDATION_ERROR', 'Post it with sign-off-axi post --type evidence --title "<what this shows>" --about "<short note>" --media <file>');
|
|
1382
1443
|
const raised = incoming.map((d, i) => {
|
|
1383
1444
|
const had = current.find((c) => c.key === d.key);
|
|
1384
1445
|
// A contract message refused for its key exits 2 like any other refusal (contract section 4); raise --title keeps exit 1.
|
|
@@ -1708,10 +1769,12 @@ const tokenMissing = (n) => n.muxr
|
|
|
1708
1769
|
} })()
|
|
1709
1770
|
: n.token_env && !process.env[n.token_env] ? `${n.token_env} is not set` : undefined;
|
|
1710
1771
|
const NOTIFY_SENDER = `void (async()=>{const delays=[2000,10000,60000],f=process.env.SIGN_OFF_NOTIFY_TOKEN_FILE;for(let i=0;i<4;i++){try{const t=f?require('node:fs').readFileSync(f,'utf8').trim():process.env.SIGN_OFF_NOTIFY_TOKEN;if((await fetch(process.env.SIGN_OFF_NOTIFY_URL,{method:'POST',headers:{'content-type':'application/json',...(t&&{authorization:'Bearer '+t})},body:process.env.SIGN_OFF_NOTIFY_BODY,signal:AbortSignal.timeout(10000)})).ok)return}catch{}if(i<3)await new Promise(r=>setTimeout(r,delays[i]))}require('node:fs').appendFileSync(process.env.SIGN_OFF_NOTIFY_LOG,new Date().toISOString()+' notification send failed for card '+process.env.SIGN_OFF_NOTIFY_ID+'\\n')})();`;
|
|
1711
|
-
/**
|
|
1772
|
+
/** Cards worth a notice: those waiting on the user, and a new evidence update (shown, never waiting). */
|
|
1773
|
+
const notifies = (d) => waits(d) || (d.type === 'evidence' && !d.dismissed);
|
|
1774
|
+
/** The cards that would notify before a write: one that notifies after the write but not before it is new to the user. */
|
|
1712
1775
|
function waitingKeys(file) {
|
|
1713
1776
|
try {
|
|
1714
|
-
return new Set(board(file, () => { }).filter(
|
|
1777
|
+
return new Set(board(file, () => { }).filter(notifies).map((d) => d.key));
|
|
1715
1778
|
}
|
|
1716
1779
|
catch {
|
|
1717
1780
|
return undefined;
|
|
@@ -1725,7 +1788,7 @@ function notifyEntered(file, named, before) {
|
|
|
1725
1788
|
const so = dirname(named), notify = before ? readSetup(so).notify : undefined;
|
|
1726
1789
|
if (!before || !notify || tokenMissing(notify))
|
|
1727
1790
|
return;
|
|
1728
|
-
const { items } = readItems(file), fresh = board(file, () => { }).filter((d) =>
|
|
1791
|
+
const { items } = readItems(file), fresh = board(file, () => { }).filter((d) => notifies(d) && !before.has(d.key) && !items.some((i) => i?.key === d.key && i.announced));
|
|
1729
1792
|
if (!fresh.length)
|
|
1730
1793
|
return;
|
|
1731
1794
|
const at = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
|
|
@@ -2189,6 +2252,9 @@ async function exportBoard(args) {
|
|
|
2189
2252
|
return toon({ board: tilde(parsed.file), exported: tilde(out), items: frozen.decisions.length, inlined: Object.keys(files).length, ...(missing && { missing }) }, [`Open ${tilde(out)} in a browser: one read-only file, no server needed`]);
|
|
2190
2253
|
}
|
|
2191
2254
|
const page = readFileSync(new URL('../web/index.html', import.meta.url));
|
|
2255
|
+
/** The board's favicon, home-screen icon and install manifest, served beside the page; see web/icon.svg. */
|
|
2256
|
+
const ICONS = Object.fromEntries([['icon.svg', 'image/svg+xml'], ['apple-touch-icon.png', 'image/png'], ['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['icon-maskable-512.png', 'image/png'], ['manifest.webmanifest', 'application/manifest+json']]
|
|
2257
|
+
.map(([name, type]) => [`/${name}`, { type, bytes: readFileSync(new URL(`../web/${name}`, import.meta.url)) }]));
|
|
2192
2258
|
/** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
|
|
2193
2259
|
const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
|
|
2194
2260
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
@@ -2345,10 +2411,22 @@ function under(root, rel) {
|
|
|
2345
2411
|
throw new EvidenceError('mock path escapes the board folder');
|
|
2346
2412
|
return actual;
|
|
2347
2413
|
}
|
|
2348
|
-
|
|
2414
|
+
/** The folder holding a run's record: --evidence-root, else the app folder done-done keeps it in (~/.done-done/evidence/<app>); '' when no folder holds it. */
|
|
2415
|
+
function rootOf(runId) {
|
|
2416
|
+
if (evidenceRoot)
|
|
2417
|
+
return evidenceRoot;
|
|
2418
|
+
const store = join(homedir(), '.done-done', 'evidence');
|
|
2419
|
+
try {
|
|
2420
|
+
return readdirSync(store).map((app) => join(store, app)).find((dir) => existsSync(join(dir, '.done-done', 'runs', `${runId}.json`))) ?? '';
|
|
2421
|
+
}
|
|
2422
|
+
catch {
|
|
2423
|
+
return '';
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
function contained(evidence, path, missing = 'A picture or recording of the proof is missing.') {
|
|
2349
2427
|
let root, actual;
|
|
2350
2428
|
try {
|
|
2351
|
-
root = realpathSync(
|
|
2429
|
+
root = realpathSync(evidence);
|
|
2352
2430
|
actual = realpathSync(resolve(root, path));
|
|
2353
2431
|
}
|
|
2354
2432
|
catch {
|
|
@@ -2359,12 +2437,13 @@ function contained(path, missing = 'A picture or recording of the proof is missi
|
|
|
2359
2437
|
return actual;
|
|
2360
2438
|
}
|
|
2361
2439
|
/** A linked run as the board checked it; the reason a check failed is a plain sentence for the card.
|
|
2362
|
-
* With no --evidence-root the board
|
|
2440
|
+
* With no --evidence-root the board looks in done-done's own folder for the app; a run it finds nowhere cannot be checked, so the card shows no result rather than one that blames the run. */
|
|
2363
2441
|
function evidenceCard(link) {
|
|
2364
|
-
|
|
2442
|
+
const root = rootOf(link.run_id);
|
|
2443
|
+
if (!root)
|
|
2365
2444
|
return null;
|
|
2366
2445
|
try {
|
|
2367
|
-
const recordPath = contained(`.done-done/runs/${link.run_id}.json`, 'The proof for this run is missing.');
|
|
2446
|
+
const recordPath = contained(root, `.done-done/runs/${link.run_id}.json`, 'The proof for this run is missing.');
|
|
2368
2447
|
if (statSync(recordPath).size > 1_000_000)
|
|
2369
2448
|
throw new EvidenceError('The proof for this run is too large to read.');
|
|
2370
2449
|
const run = JSON.parse(readFileSync(recordPath, 'utf8'));
|
|
@@ -2379,7 +2458,7 @@ function evidenceCard(link) {
|
|
|
2379
2458
|
const type = mediaTypes[extname(f.path ?? '').toLowerCase()];
|
|
2380
2459
|
if (!type)
|
|
2381
2460
|
return [];
|
|
2382
|
-
const path = contained(f.path);
|
|
2461
|
+
const path = contained(root, f.path);
|
|
2383
2462
|
if (statSync(path).size > 20_000_000)
|
|
2384
2463
|
throw new EvidenceError('A picture or recording of the proof is too large.');
|
|
2385
2464
|
const bytes = readFileSync(path);
|
|
@@ -2442,7 +2521,7 @@ async function route(req) {
|
|
|
2442
2521
|
const card = evidenceCard(decision.evidence);
|
|
2443
2522
|
if (card?.status !== 'verified' || !card.media.some((m) => m.path === path))
|
|
2444
2523
|
return [404, { error: 'Evidence is unavailable' }];
|
|
2445
|
-
const actual = contained(path), type = mediaTypes[extname(path).toLowerCase()];
|
|
2524
|
+
const actual = contained(rootOf(runId), path), type = mediaTypes[extname(path).toLowerCase()];
|
|
2446
2525
|
return [200, { media: readFileSync(actual), type }];
|
|
2447
2526
|
}
|
|
2448
2527
|
if (req.method === 'GET' && req.url?.startsWith('/api/media?')) {
|
|
@@ -2749,6 +2828,9 @@ const server = createServer(async (req, res) => {
|
|
|
2749
2828
|
if (given !== null)
|
|
2750
2829
|
return res.writeHead(303, { location: '/', 'set-cookie': `${cookie}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` }).end();
|
|
2751
2830
|
}
|
|
2831
|
+
const icon = req.method === 'GET' ? ICONS[req.url ?? ''] : undefined;
|
|
2832
|
+
if (icon)
|
|
2833
|
+
return res.writeHead(200, { 'content-type': icon.type, 'cache-control': 'public, max-age=86400' }).end(icon.bytes);
|
|
2752
2834
|
if (req.method === 'GET' && req.url === '/favicon.ico')
|
|
2753
2835
|
return res.writeHead(204).end();
|
|
2754
2836
|
if (req.method === 'GET' && req.url === '/') {
|
|
@@ -2788,7 +2870,7 @@ await runAxiCli({
|
|
|
2788
2870
|
topLevelHelp: TOP_HELP,
|
|
2789
2871
|
getCommandHelp: (cmd) => HELP[cmd],
|
|
2790
2872
|
home: axi('list', list),
|
|
2791
|
-
commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), signin: axi('signin', signin), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
|
|
2873
|
+
commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), signin: axi('signin', signin), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), refile: axi('refile', refile), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
|
|
2792
2874
|
if (rest.length || (name && !HELP[name]))
|
|
2793
2875
|
throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
|
|
2794
2876
|
return name ? HELP[name] : TOP_HELP;
|
package/dist/store.js
CHANGED
|
@@ -26,6 +26,8 @@ const STORED = {
|
|
|
26
26
|
};
|
|
27
27
|
export const CHAPTERS_MAX = 12;
|
|
28
28
|
const isText = (v) => typeof v === 'string';
|
|
29
|
+
/** An http(s) link of up to 500 characters with no spaces: an item's `link` (besides a commit hash) and a design option's. */
|
|
30
|
+
const HTTP_LINK = /^https?:\/\/\S{1,500}$/u;
|
|
29
31
|
export const isKey = (v) => isText(v) && /^[A-Za-z0-9._-]{1,128}$/.test(v);
|
|
30
32
|
const ONE_OF = { close: ['done', 'release'], area: ['waiting', 'evidence', 'reports', 'answered'] };
|
|
31
33
|
/** What a step uses or can stop on: an AI model, a store, a gate, a hard fail. */
|
|
@@ -135,7 +137,7 @@ export function loadDecisions(path, board) {
|
|
|
135
137
|
if (d.type === 'evidence' && !(Number.isInteger(d.number) && d.number >= 1))
|
|
136
138
|
throw bad('number', 'must be a whole number from 1');
|
|
137
139
|
const signin = d.type === 'signin';
|
|
138
|
-
if (d.link !== undefined && !(isText(d.link) && /^
|
|
140
|
+
if (d.link !== undefined && !(isText(d.link) && (HTTP_LINK.test(d.link) || /^[a-f0-9]{7,40}$/u.test(d.link))) && !signin)
|
|
139
141
|
throw bad('link', 'must be an http(s) link or a commit hash');
|
|
140
142
|
// A sign-in is a link to open, what it unlocks and who waits; its answer is the `signin done` its poster saves, so the user gets no buttons.
|
|
141
143
|
if (signin) {
|
|
@@ -205,6 +207,8 @@ export function loadDecisions(path, board) {
|
|
|
205
207
|
|| !d.mocks.every((m) => isKey(m?.value) && isText(m?.path) && m.path.trim() && m.path.length <= 256 && /^[^?#]+(\.html\?variant=[A-Za-z0-9._-]{1,64})?$/i.test(m.path))) {
|
|
206
208
|
throw bad('mocks', 'must list up to 6 items, each an option value and a board-relative mock path (an .html page may end in ?variant=<name>)');
|
|
207
209
|
}
|
|
210
|
+
if (!d.mocks.every((m) => m.link === undefined || (isText(m.link) && HTTP_LINK.test(m.link))))
|
|
211
|
+
throw bad('mocks', 'a link must be an http(s) link with no spaces');
|
|
208
212
|
const targets = d.mocks.map((m) => m.value);
|
|
209
213
|
if (new Set(targets).size < targets.length)
|
|
210
214
|
throw bad('mocks', 'option values must be unique');
|
|
@@ -216,7 +220,7 @@ export function loadDecisions(path, board) {
|
|
|
216
220
|
key: d.key, title: d.title, about: d.about, detail: d.detail, options: options.map((o) => ({ value: o.value, label: o.label, ...(o.outcome && { outcome: o.outcome }) })),
|
|
217
221
|
...(d.why && { why: d.why }), ...(d.recommend_reason && { recommend_reason: d.recommend_reason }), ...(d.silent_by && { silent_by: d.silent_by }), ...(d.merges && { merges: [...d.merges] }), ...(d.proof && { proof: d.proof }),
|
|
218
222
|
recommend_value: d.recommend_value, default_if_silent: d.default_if_silent, close: d.close ?? 'done', evidence: d.evidence,
|
|
219
|
-
type: plan ? 'plan' : d.type ?? 'decision', tradeoff: d.tradeoff, link: d.link, mocks: d.mocks?.map((m) => ({ value: m.value, path: m.path })),
|
|
223
|
+
type: plan ? 'plan' : d.type ?? 'decision', tradeoff: d.tradeoff, link: d.link, mocks: d.mocks?.map((m) => ({ value: m.value, path: m.path, ...(m.link !== undefined && { link: m.link }) })),
|
|
220
224
|
...(views && { view: d.view }), ...(report && views && { preview: d.preview }), ...(d.reply && { reply: true }), ...(d.filer && { filer: d.filer }), ...(d.area && { area: d.area }),
|
|
221
225
|
...(signin && { unlocks: d.unlocks, waits: d.waits, ...(d.expires && { expires: d.expires }), ...(d.start && { start: [...d.start] }) }),
|
|
222
226
|
...(d.group && { group: d.group }), ...(d.filed && { filed: d.filed }), ...(d.decided && { decided: d.decided }),
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sign-off-axi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "One review board for evidence, quick yes/no decisions and retro changes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": { "sign-off-axi": "dist/cli.js" },
|
|
7
7
|
"files": ["dist", "!dist/skill.js", "web", "fixtures/contract"],
|
|
8
8
|
"engines": { "node": ">=22.5" },
|
|
9
9
|
"license": "MIT",
|
|
10
|
+
"homepage": "https://umeranjum17.github.io/sign-off/",
|
|
10
11
|
"repository": { "type": "git", "url": "git+https://github.com/umeranjum17/sign-off.git" },
|
|
11
12
|
"keywords": ["axi", "agent", "cli", "toon", "decisions", "review"],
|
|
12
13
|
"scripts": {
|
|
Binary file
|
package/web/icon-192.png
ADDED
|
Binary file
|
package/web/icon-512.png
ADDED
|
Binary file
|
|
Binary file
|
package/web/icon.svg
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="l" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#6A93FF"/><stop offset="1" stop-color="#2C48D3"/></linearGradient>
|
|
4
|
+
<linearGradient id="d" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2C2C2E"/><stop offset="1" stop-color="#0B0B0C"/></linearGradient>
|
|
5
|
+
<linearGradient id="h" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".28"/><stop offset=".35" stop-color="#fff" stop-opacity="0"/></linearGradient>
|
|
6
|
+
</defs>
|
|
7
|
+
<path d="M1024,512L1024,646L1024,688L1023,719L1022,745L1021,766L1019,785L1018,802L1016,818L1014,832L1011,845L1009,858L1006,869L1003,880L999,890L995,900L991,909L987,918L982,926L977,934L972,941L967,948L961,955L955,961L948,967L941,972L934,977L926,982L918,987L909,991L900,995L890,999L880,1003L869,1006L858,1009L845,1011L832,1014L818,1016L802,1018L785,1019L766,1021L745,1022L719,1023L688,1024L646,1024L512,1024L378,1024L336,1024L305,1023L279,1022L258,1021L239,1019L222,1018L206,1016L192,1014L179,1011L166,1009L155,1006L144,1003L134,999L124,995L115,991L106,987L98,982L90,977L83,972L76,967L69,961L63,955L57,948L52,941L47,934L42,926L37,918L33,909L29,900L25,890L21,880L18,869L15,858L13,845L10,832L8,818L6,802L5,785L3,766L2,745L1,719L0,688L0,646L0,512L0,378L0,336L1,305L2,279L3,258L5,239L6,222L8,206L10,192L13,179L15,166L18,155L21,144L25,134L29,124L33,115L37,106L42,98L47,90L52,83L57,76L63,69L69,63L76,57L83,52L90,47L98,42L106,37L115,33L124,29L134,25L144,21L155,18L166,15L179,13L192,10L206,8L222,6L239,5L258,3L279,2L305,1L336,0L378,0L512,0L646,0L688,0L719,1L745,2L766,3L785,5L802,6L818,8L832,10L845,13L858,15L869,18L880,21L890,25L900,29L909,33L918,37L926,42L934,47L941,52L948,57L955,63L961,69L967,76L972,83L977,90L982,98L987,106L991,115L995,124L999,134L1003,144L1006,155L1009,166L1011,179L1014,192L1016,206L1018,222L1019,239L1021,258L1022,279L1023,305L1024,336L1024,378Z" fill="url(#l)"/>
|
|
8
|
+
<path d="M1024,512L1024,646L1024,688L1023,719L1022,745L1021,766L1019,785L1018,802L1016,818L1014,832L1011,845L1009,858L1006,869L1003,880L999,890L995,900L991,909L987,918L982,926L977,934L972,941L967,948L961,955L955,961L948,967L941,972L934,977L926,982L918,987L909,991L900,995L890,999L880,1003L869,1006L858,1009L845,1011L832,1014L818,1016L802,1018L785,1019L766,1021L745,1022L719,1023L688,1024L646,1024L512,1024L378,1024L336,1024L305,1023L279,1022L258,1021L239,1019L222,1018L206,1016L192,1014L179,1011L166,1009L155,1006L144,1003L134,999L124,995L115,991L106,987L98,982L90,977L83,972L76,967L69,961L63,955L57,948L52,941L47,934L42,926L37,918L33,909L29,900L25,890L21,880L18,869L15,858L13,845L10,832L8,818L6,802L5,785L3,766L2,745L1,719L0,688L0,646L0,512L0,378L0,336L1,305L2,279L3,258L5,239L6,222L8,206L10,192L13,179L15,166L18,155L21,144L25,134L29,124L33,115L37,106L42,98L47,90L52,83L57,76L63,69L69,63L76,57L83,52L90,47L98,42L106,37L115,33L124,29L134,25L144,21L155,18L166,15L179,13L192,10L206,8L222,6L239,5L258,3L279,2L305,1L336,0L378,0L512,0L646,0L688,0L719,1L745,2L766,3L785,5L802,6L818,8L832,10L845,13L858,15L869,18L880,21L890,25L900,29L909,33L918,37L926,42L934,47L941,52L948,57L955,63L961,69L967,76L972,83L977,90L982,98L987,106L991,115L995,124L999,134L1003,144L1006,155L1009,166L1011,179L1014,192L1016,206L1018,222L1019,239L1021,258L1022,279L1023,305L1024,336L1024,378Z" fill="none" stroke="url(#h)" stroke-width="6" transform="translate(512 512) scale(.994) translate(-512 -512)"/>
|
|
9
|
+
<g fill="none" stroke="#FFFFFF" stroke-width="96" stroke-linecap="round" stroke-linejoin="round"><path d="M281 520 L443 682 L743 342"/></g>
|
|
10
|
+
</svg>
|
package/web/index.html
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
6
|
<title>Dashboard · sign-off</title>
|
|
7
|
+
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
|
8
|
+
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
|
9
|
+
<link rel="manifest" href="manifest.webmanifest">
|
|
10
|
+
<meta name="theme-color" content="#F5F5F7" media="(prefers-color-scheme: light)">
|
|
11
|
+
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
|
|
7
12
|
<style>
|
|
8
13
|
/* Spacious, Apple grade. A phone reads white tiles on a grey grouped page; a desk reads grey tiles on a white page (the 60rem block swaps them).
|
|
9
14
|
Dark is its own scale: a black page with #161617 tiles. --accent is the blue for text, links and focus; --action is the blue a button is filled with. */
|
|
@@ -414,6 +419,7 @@
|
|
|
414
419
|
margin-top: 1rem; padding: .75rem 0 .5rem; background: var(--bg); }
|
|
415
420
|
/* Answer takes the row on one line; when a long label leaves no room, Skip and Later move under it rather than squeeze it. */
|
|
416
421
|
.decide-bar > * { white-space: nowrap; }
|
|
422
|
+
.decide-bar > .silent-line { white-space: normal; min-width: 0; } /* the stay-quiet line wraps; nowrap pushed it past a phone's edge in a wider font */
|
|
417
423
|
.decide-bar .answer { flex: 1 1 auto; background: var(--action); color: var(--on-accent); font-weight: 400; }
|
|
418
424
|
.decide-bar .later { display: inline-flex; flex: none; align-items: center; justify-content: center; min-height: 2.75rem; padding: 0 0.75rem; color: var(--accent); background: transparent; text-decoration: none; }
|
|
419
425
|
.decide-bar .skip { flex: none; padding: 0 0.75rem; color: var(--muted); background: transparent; }
|
|
@@ -437,8 +443,9 @@
|
|
|
437
443
|
.ev-card .t { font-size: 1.1875rem; font-weight: 600; line-height: 1.32; letter-spacing: -0.012em; }
|
|
438
444
|
.ev-group h2 { font-size: 1.375rem; line-height: 1.27; font-weight: 700; letter-spacing: -0.012em; }
|
|
439
445
|
.ev-card .m { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; color: var(--muted); font-size: 0.9375rem; }
|
|
440
|
-
.ev-ok { display: inline-flex; align-items: center; gap: 0.25rem; color: var(--ok); }
|
|
441
|
-
.ev-
|
|
446
|
+
.ev-ok, .ev-bad, .ev-warn { display: inline-flex; align-items: center; gap: 0.25rem; color: var(--ok); }
|
|
447
|
+
.ev-bad { color: var(--danger); } .ev-warn { color: var(--warn); } .ev-none { color: var(--muted); }
|
|
448
|
+
.ev-ok svg, .ev-bad svg, .ev-warn svg { width: 0.9375rem; height: 0.9375rem; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
|
|
442
449
|
/* One update: the whole capture on its tile, what it proves in a line, what the check recorded, then its verdict. */
|
|
443
450
|
#reader .ev-scroll { flex: 1; min-height: 0; overflow: auto; padding: 1rem 1.25rem 3rem; }
|
|
444
451
|
.ev-item { display: grid; gap: 1rem; max-width: 42.5rem; margin: 0 auto; }
|
|
@@ -551,6 +558,8 @@
|
|
|
551
558
|
.choices.many button { flex: 1 1 auto; max-width: 100%; padding: 0.5rem 1rem; }
|
|
552
559
|
.note label { display: block; color: var(--muted); font-size: 0.875rem; margin-top: 0.5rem; }
|
|
553
560
|
.tradeoff { margin: 0; max-width: 70ch; }
|
|
561
|
+
.option-link { display: inline-flex; align-items: center; min-height: 2.75rem; font-size: 0.9375rem; color: var(--accent); }
|
|
562
|
+
.fd-links { margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 0 1rem; font-size: 0.9375rem; color: var(--muted); } .fd-links a { min-height: 2.75rem; display: inline-flex; align-items: center; color: var(--accent); }
|
|
554
563
|
/* Dismiss: a quiet round x at the corner of a card or the end of a row. */
|
|
555
564
|
.x { flex: none; display: grid; place-items: center; width: 2.75rem; min-height: 2.75rem; padding: 0; margin: -0.5rem 0 -0.5rem auto; border: 0; border-radius: 50%;
|
|
556
565
|
background: none; color: var(--muted); font-size: 1.5rem; line-height: 1.25; }
|
|
@@ -634,7 +643,8 @@
|
|
|
634
643
|
grid-auto-columns: min(85%, calc(var(--tall) * 9 / 19.5)); gap: 0.5rem 0.875rem; margin: -0.25rem; padding: 0.25rem 0.25rem 0.5rem;
|
|
635
644
|
overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scroll-padding: 0 0.25rem; }
|
|
636
645
|
.mock { display: grid; grid-row: span 3; grid-template-rows: subgrid; margin: 0; min-width: 0; scroll-snap-align: start; }
|
|
637
|
-
.mock > button { grid-row: 3; }
|
|
646
|
+
.mock > button, .mock > .under { grid-row: 3; }
|
|
647
|
+
.mock .under { display: grid; align-content: start; justify-items: start; } .mock .under button { justify-self: stretch; }
|
|
638
648
|
.mock:has([aria-pressed="true"]) .shot { border-color: var(--accent); }
|
|
639
649
|
/* A frame draws the mock at about phone width (1.7 times the frame) at that fraction, cropped from the top. */
|
|
640
650
|
.shot { position: relative; display: block; overflow: hidden; aspect-ratio: 9 / 19.5; background: #fff;
|
|
@@ -973,7 +983,7 @@
|
|
|
973
983
|
const merged = !saved && !state.dismissed?.includes(d.key) ? (d.merges ?? []).filter((k) => state.decisions.some((x) => x.key === k) && !answerOf(k)).length : 0;
|
|
974
984
|
if (design) {
|
|
975
985
|
return el('article', { class: 'row design', id: `item-${d.key}` }, lead,
|
|
976
|
-
d.
|
|
986
|
+
firstDraft(d).text ? el('p', { class: 'tradeoff', dir: 'auto' }, el('strong', {}, 'Trade-off '), firstDraft(d).text) : '', firstDraftLinks(d),
|
|
977
987
|
mockCards(d, ro ? [] : buttons), annotations(d), silent ? el('p', { class: 'silent' }, silent) : '', ro ? '' : other(d, saved, err), err);
|
|
978
988
|
}
|
|
979
989
|
// Yes/No rows keep the two fixed buttons they always had; any other choice set wraps; answers that say what they do stack.
|
|
@@ -1180,9 +1190,10 @@
|
|
|
1180
1190
|
}
|
|
1181
1191
|
/** One update on the Evidence screen: its capture as the hero, what it proves and how it went; the whole card opens the update. */
|
|
1182
1192
|
function evCard(d) {
|
|
1193
|
+
const result = RESULT[d.evidenceCard?.result];
|
|
1183
1194
|
return el('a', { class: 'ev-card', href: `#evidence/${d.key}`, id: `item-${d.key}` }, el('span', { class: 'ev-media' }, ...hero(d)),
|
|
1184
1195
|
el('span', { class: 't', dir: 'auto' }, headline(d)),
|
|
1185
|
-
el('span', { class: 'm' }, el('span', { class:
|
|
1196
|
+
el('span', { class: 'm' }, result ? el('span', { class: `ev-${result[0]}` }, mark(result[0]), result[2]) : el('span', { class: 'ev-none' }, 'Not run'), d.filed ? el('span', {}, stamp(d.filed)) : ''));
|
|
1186
1197
|
}
|
|
1187
1198
|
/** Evidence as its own screen: a card per passed update, grouped under the project its check names, in the order each project first shows;
|
|
1188
1199
|
* proof that names no project goes under Other, last. An item put in this area that is not an update (a page, a diagram) keeps the card it always had. */
|
|
@@ -1257,6 +1268,16 @@
|
|
|
1257
1268
|
return el('div', { class: 'rung' }, body, el('a', { class: 'meta', ...open, 'aria-label': `Open the ${name} full size, ${d.title}` }, 'Open full size'));
|
|
1258
1269
|
}
|
|
1259
1270
|
|
|
1271
|
+
/** first-draft's `post` ends a design's trade-off with `Open in first-draft: <label> <url> · <label> <url>`: those urls, and the text before them. */
|
|
1272
|
+
const firstDraft = (d) => {
|
|
1273
|
+
const at = (d.tradeoff ?? '').search(/(?:^|\s)Open in first-draft: /), tail = at < 0 ? '' : d.tradeoff.slice(at).replace(/^\s*Open in first-draft: /, '');
|
|
1274
|
+
const links = tail.split(' · ').map((p) => /^(.*?)\s*(https?:\/\/\S+)$/.exec(p.trim())).filter(Boolean).map(([, label, href]) => ({ label: label || href, href }));
|
|
1275
|
+
return links.length ? { text: d.tradeoff.slice(0, at).trim(), links } : { text: d.tradeoff, links: [] };
|
|
1276
|
+
};
|
|
1277
|
+
/** Real links on the card itself: a link inside a mock is blocked by the frame's sandbox. */
|
|
1278
|
+
const firstDraftLinks = (d) => { const { links } = firstDraft(d);
|
|
1279
|
+
return links.length ? el('p', { class: 'fd-links' }, 'Open in first-draft ', ...links.map((l) => el('a', { href: l.href, target: '_blank', rel: 'noopener' }, l.label))) : ''; };
|
|
1280
|
+
|
|
1260
1281
|
/** One phone frame per option, the recommended one first: its label and Recommended badge, the mock (tap opens it full size) and its Pick button. */
|
|
1261
1282
|
function mockCards(d, buttons) {
|
|
1262
1283
|
const rec = (i) => d.options[i].value === d.recommend_value, order = d.options.map((_, i) => i).sort((a, b) => rec(b) - rec(a));
|
|
@@ -1271,7 +1292,9 @@
|
|
|
1271
1292
|
el('figcaption', {}, el('span', { class: 'label', dir: 'auto' }, o.label), rec(i) ? el('span', { class: 'badge' }, 'Recommended') : ''),
|
|
1272
1293
|
// A mock file that is gone has a null hash: the option says so in place of an empty frame.
|
|
1273
1294
|
m && m.hash !== null ? el('a', { class: 'shot', href: url, target: '_blank', rel: 'noopener', 'aria-label': `Open mock ${o.label} full size` }, view)
|
|
1274
|
-
: el('p', { class: 'shot none' }, 'No mock for this option'),
|
|
1295
|
+
: el('p', { class: 'shot none' }, 'No mock for this option'),
|
|
1296
|
+
// The option's own link sits on the card, where a tap opens a tab: a link inside the sandboxed frame cannot.
|
|
1297
|
+
m?.link ? el('div', { class: 'under' }, el('a', { class: 'option-link', href: m.link, target: '_blank', rel: 'noopener', 'aria-label': `Open ${o.label} live` }, 'Open live'), buttons[i] ?? '') : buttons[i] ?? '');
|
|
1275
1298
|
}));
|
|
1276
1299
|
}
|
|
1277
1300
|
|
|
@@ -1368,13 +1391,12 @@
|
|
|
1368
1391
|
const icon = (a) => { const s = el('span', { class: `icon i-${a}`, 'aria-hidden': 'true' }); s.innerHTML = `<svg viewBox="0 0 24 24"><path d="${ICONS[a]}"/></svg>`; return s; };
|
|
1369
1392
|
// A design with only screen notes (answer 'screens') still waits for its design-wide verdict.
|
|
1370
1393
|
const isAnswered = (d) => Object.hasOwn(state.answers, d.key) && !(d.type === 'design' && state.answers[d.key].answer === 'screens');
|
|
1371
|
-
/**
|
|
1394
|
+
/** A sign-in whose link has expired has nothing left to open: it leaves the board until its poster posts a new link on the same key. */
|
|
1395
|
+
const lapsed = (d) => d.type === 'signin' && !d.start && !!d.expires && Date.parse(d.expires) <= Date.now() && !isAnswered(d);
|
|
1396
|
+
/** Each item lives in one area: answered items, then the area the item names, then evidence updates (never an ask, whatever their result), then what is only to read (explainers, pages with nothing to answer), then the open asks.
|
|
1372
1397
|
* A plan has no options but waits for its send-back. */
|
|
1373
|
-
/** An update the Evidence list shows: passed proof only; every other update (no run, a failed or unfinished check) waits on you. */
|
|
1374
|
-
const listed = (d) => d.evidenceCard?.result === 'RUN PASS';
|
|
1375
1398
|
const area = (d) => {
|
|
1376
|
-
const a = isAnswered(d) ? 'answered' : d.type === 'evidence'
|
|
1377
|
-
: state.readonly || d.type === 'explainer' || !(d.options.length || d.reply || d.type === 'signin' || d.type === 'plan' || d.kind === 'design') ? 'reports' : 'waiting');
|
|
1399
|
+
const a = isAnswered(d) ? 'answered' : d.type === 'evidence' ? 'evidence' : d.area ?? (state.readonly || d.type === 'explainer' || !(d.options.length || d.reply || d.type === 'signin' || d.type === 'plan' || d.kind === 'design') ? 'reports' : 'waiting');
|
|
1378
1400
|
// Reports lists only what explains: a page to tap through, or a narrated video. Text alone (a receipt, a status line) is a note, folded under it.
|
|
1379
1401
|
return a === 'reports' && !state.readonly && d.type !== 'explainer' && !reads(d) ? 'notes' : a;
|
|
1380
1402
|
};
|
|
@@ -1428,10 +1450,10 @@
|
|
|
1428
1450
|
}
|
|
1429
1451
|
/** Today at a glance, in a few short sentences: what needs you and what was proven in full colour, when the soonest ask closes and the new reports quieter. */
|
|
1430
1452
|
function glance(by) {
|
|
1431
|
-
const n = by.waiting.length, proven =
|
|
1453
|
+
const n = by.waiting.length, proven = by.provenToday.length, fresh = filedToday(by.reports).length;
|
|
1432
1454
|
const soonest = by.waiting.map((d) => d.silent_by).filter(Boolean).sort((x, y) => Date.parse(x) - Date.parse(y))[0];
|
|
1433
1455
|
const lines = [[n ? `${n} ${n === 1 ? 'decision needs' : 'decisions need'} you.` : 'Nothing needs you.', 1], [soonest && `The soonest closes at ${when(soonest)}.`],
|
|
1434
|
-
[proven && `${proven} ${proven === 1 ? '
|
|
1456
|
+
[proven && `${proven} ${proven === 1 ? 'update' : 'updates'} filed.`, 1], [fresh && `${fresh} new ${fresh === 1 ? 'report is' : 'reports are'} waiting.`]];
|
|
1435
1457
|
if (lastSeen === undefined) try { lastSeen = localStorage.getItem(seenKey()); } catch { lastSeen = null; }
|
|
1436
1458
|
return el('p', { class: 'card glance' }, lastSeen && !Number.isNaN(Date.parse(lastSeen)) ? el('span', { class: 'seen' }, `Since you last looked at ${when(lastSeen)}`) : '', ...lines.filter(([t]) => t).map(([t, strong]) => el(strong ? 'b' : 'span', {}, `${t} `)));
|
|
1437
1459
|
}
|
|
@@ -1471,9 +1493,8 @@
|
|
|
1471
1493
|
return el('div', { class: 'search', role: 'search', ...(query === null && { hidden: '' }) }, box, out);
|
|
1472
1494
|
}
|
|
1473
1495
|
|
|
1474
|
-
/** Proven today on Today: a swipeable row of today's
|
|
1475
|
-
function proven(items) {
|
|
1476
|
-
const fresh = items.filter((d) => d.type === 'evidence' && d.filed && isToday(d.filed));
|
|
1496
|
+
/** Proven today on Today: a swipeable row of today's updates, each with its own result and capture; a wide screen sets them three across. */
|
|
1497
|
+
function proven(items, fresh) {
|
|
1477
1498
|
return el('section', { class: 'i-evidence wide' }, el('div', { class: 'sec-head' }, el('h2', {}, 'Proven today'), items.length ? el('a', { href: '#evidence' }, 'See all') : ''),
|
|
1478
1499
|
fresh.length ? el('div', { class: 'ev-scroll-row' }, ...fresh.slice(0, 3).map((d) => withX(d, evCard(d)))) : el('p', { class: 'empty' }, 'Nothing proven today yet.'));
|
|
1479
1500
|
}
|
|
@@ -1975,7 +1996,9 @@
|
|
|
1975
1996
|
});
|
|
1976
1997
|
};
|
|
1977
1998
|
if (d.type === 'signin') {
|
|
1978
|
-
const
|
|
1999
|
+
const gone = xBtn(d, 'Dismiss');
|
|
2000
|
+
if (gone) gone.addEventListener('click', () => (location.hash = to));
|
|
2001
|
+
const signinPage = el('div', { class: 'decide' }, row(d, { x: false }), el('div', { class: 'decide-bar' }, skip || '', el('a', { class: 'later', href: '#waiting' }, 'Later'), gone), decideLists(d, queue));
|
|
1979
2002
|
if (!state.readonly) swipe(signinPage);
|
|
1980
2003
|
return signinPage;
|
|
1981
2004
|
}
|
|
@@ -2000,12 +2023,12 @@
|
|
|
2000
2023
|
const quiet = opts.find((o) => o.value === d.default_if_silent);
|
|
2001
2024
|
const silent = quiet ? [icon('clock'), el('span', {}, el('b', {}, 'If you stay quiet:'), ` ${quiet.outcome ?? quiet.label}${d.silent_by ? ` At ${when(d.silent_by)}. ${left(d.silent_by)}.` : ''}`)] : '';
|
|
2002
2025
|
const head = el('div', { class: 'lead' }, badge(d), el('h2', { dir: 'auto' }, headline(d)));
|
|
2003
|
-
const why = unchecked(d) || d.why || d.tradeoff || d.about;
|
|
2026
|
+
const why = unchecked(d) || d.why || (d.tradeoff && firstDraft(d).text) || d.about;
|
|
2004
2027
|
const back = el('a', { class: 'later', href: '#waiting' }, 'Later'), gone = xBtn(d, 'Dismiss');
|
|
2005
2028
|
if (gone) gone.addEventListener('click', () => (location.hash = to));
|
|
2006
2029
|
const page = el('section', { class: 'decide' }, hero || (preview ? el('div', { class: 'decide-preview' }, preview) : ''),
|
|
2007
2030
|
...(d.mocks?.length ? [annotations(d)] : []), head,
|
|
2008
|
-
why ? el('p', { class: 'why', dir: 'auto' }, why) : '',
|
|
2031
|
+
why ? el('p', { class: 'why', dir: 'auto' }, why) : '', firstDraftLinks(d),
|
|
2009
2032
|
...(live && opts.length ? [el('p', { class: 'pick-label' }, typeOf(d) === 'yes-no' ? 'YES OR NO' : 'PICK ONE'),
|
|
2010
2033
|
el('ul', { class: 'pick-list', 'aria-label': d.title }, ...optionButtons.map((b) => el('li', {}, b)))] : []),
|
|
2011
2034
|
err,
|
|
@@ -2033,9 +2056,11 @@
|
|
|
2033
2056
|
const gone = new Set(state.dismissed ?? []), open = state.decisions.filter((d) => !gone.has(d.key) && !isAnswered(d));
|
|
2034
2057
|
const openKeys = new Set(open.map((d) => d.key));
|
|
2035
2058
|
const merged = new Set(open.flatMap((d) => d.merges ?? []).filter((k) => openKeys.has(k)));
|
|
2036
|
-
for (const d of [...state.decisions].reverse()) if (!gone.has(d.key) && !merged.has(d.key)) by[area(d)].push(d);
|
|
2059
|
+
for (const d of [...state.decisions].reverse()) if (!gone.has(d.key) && !merged.has(d.key) && !lapsed(d)) by[area(d)].push(d);
|
|
2037
2060
|
by.evidence.sort((x, y) => (Date.parse(y.filed) || 0) - (Date.parse(x.filed) || 0));
|
|
2038
2061
|
by.designs = [...state.decisions].reverse().filter((d) => d.kind === 'design' && !gone.has(d.key));
|
|
2062
|
+
// Proven today counts every update filed today that is not dismissed, answered or not: a verdict moves it off Evidence, not out of today's count.
|
|
2063
|
+
by.provenToday = filedToday(state.decisions.filter((d) => d.type === 'evidence' && !gone.has(d.key))).sort((x, y) => (Date.parse(y.filed) || 0) - (Date.parse(x.filed) || 0));
|
|
2039
2064
|
// Waiting on you reads newest first unless the viewer chose oldest first. A sign-in blocks someone now, so sign-ins come first either way.
|
|
2040
2065
|
let latest = -Infinity;
|
|
2041
2066
|
const inFileOrder = by.waiting.slice().reverse().map((d, i) => { const t = Date.parse(d.filed); if (!Number.isNaN(t)) latest = t; return { d, t: latest, i }; });
|
|
@@ -2077,15 +2102,15 @@
|
|
|
2077
2102
|
? 'How the tools work, step by step. Tap one to open it.' : 'Explainers you asked for, or that a decision needs.')
|
|
2078
2103
|
: at === 'answered' && by.answered.length ? el('p', { class: 'lede' }, (() => { const n = by.answered.filter((d) => isToday(state.answers[d.key].at)).length; return `${n} ${n === 1 ? 'answer' : 'answers'} today. Each one has gone back to its agent.`; })())
|
|
2079
2104
|
: at === 'designs' && by.designs.length ? el('p', { class: 'lede' }, 'Designs to look over. Approve one, or say what should change.')
|
|
2080
|
-
: at === 'evidence' && by.evidence.length ? el('p', { class: 'lede' }, '
|
|
2105
|
+
: at === 'evidence' && by.evidence.length ? el('p', { class: 'lede' }, 'Updates that show a change works, newest first.') : '', at === 'decide' ? '' : bulkBar(bulk));
|
|
2081
2106
|
document.getElementById('board').replaceChildren(top, ...(at === 'waiting' && !state.readonly ? [filters(by.waiting), waitingPage(shown, hidden)]
|
|
2082
2107
|
: at === 'decide' ? [decideAt < 0 ? (key ? el('p', { class: 'empty' }, 'Nothing to decide for that question.') : el('div', { class: 'decide' }, decideLists(null, decideQueue)))
|
|
2083
2108
|
: decidePage(decideQueue[decideAt], decideQueue.length > 1 ? decideQueue[(decideAt + 1) % decideQueue.length] : undefined, decideQueue)]
|
|
2084
2109
|
: at === 'designs' ? [...designGroups(by.designs)] : at === 'evidence' ? evidenceGroups(by.evidence) : at === 'reports' && !state.readonly ? [by.reports.some((d) => d.steps) ? el('p', { class: 'card legend' }, ...Object.keys(MARKS).map((m) => el('span', {}, el('span', { class: `mk mk-${m}` }), MARKS[m]))) : '',
|
|
2085
2110
|
...(by.reports.length ? reportGroups(by.reports) : [el('p', { class: 'empty' }, EMPTY.reports)]), notes(by.notes)] : at === 'answered' && by.answered.length ? done(by.answered) : at ? [at === 'waiting' ? filters(by.waiting) : '', by[at].length ? el('div', { class: `list in-${at}${state.readonly ? ' readonly' : ''}` }, ...(at === 'waiting' ? shown : by[at]).map(row))
|
|
2086
2111
|
: el('p', { class: 'empty' }, at === 'answered' && state.decisions.some((d) => gone.has(d.key) && isAnswered(d)) ? 'All done items cleared.' : EMPTY[at])] : [search(), glance(by),
|
|
2087
|
-
el('div', { class: 'tiles' }, tile('waiting', by.waiting.length, 'Need you'), tile('evidence',
|
|
2088
|
-
el('div', { class: 'areas' }, waitingSection(by.waiting, by.answered, by.designs), proven(by.evidence), newReport(by.reports)), ...(state.leash ? leash() : []),
|
|
2112
|
+
el('div', { class: 'tiles' }, tile('waiting', by.waiting.length, 'Need you'), tile('evidence', by.provenToday.length, 'Proven today'), tile('reports', filedToday(by.reports).length, 'New report')),
|
|
2113
|
+
el('div', { class: 'areas' }, waitingSection(by.waiting, by.answered, by.designs), proven(by.evidence, by.provenToday), newReport(by.reports)), ...(state.leash ? leash() : []),
|
|
2089
2114
|
...(FROZEN || state.readonly || state.answers_only ? [] : [ask()])]));
|
|
2090
2115
|
readPage(key ? state.decisions.find((d) => d.key === key && !gone.has(d.key) && (at === 'reports' && reads(d) || at === 'designs' && d.kind === 'design' || at === 'evidence' && d.type === 'evidence' || d.type === 'plan' && area(d) === at)) : undefined, at);
|
|
2091
2116
|
if (at === 'decide') document.querySelector('#board .decide')?.prepend(bulkBar(bulk));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sign-off",
|
|
3
|
+
"short_name": "sign-off",
|
|
4
|
+
"description": "One calm page for every question the programs working for you need answered.",
|
|
5
|
+
"start_url": "./",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#F5F5F7",
|
|
8
|
+
"theme_color": "#0071E3",
|
|
9
|
+
"icons": [
|
|
10
|
+
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
|
|
11
|
+
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png" },
|
|
12
|
+
{ "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
|
13
|
+
]
|
|
14
|
+
}
|