@popcorn245/cornfield 0.6.0 → 0.8.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.
@@ -0,0 +1,259 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/popcorn245/CornField/blob/main/core/agent-contract.schema.json",
4
+ "title": "cornfield agent contract, version 1",
5
+ "description": "The JSON a cornfield-run fenced block holds: a request, a report or a stop request. docs/agent-contract.md says what each means. Checks the shape; a few rules are the validator's alone (the configured profile list, that a date is a real one), and are listed in the docs.",
6
+ "oneOf": [
7
+ {
8
+ "$ref": "#/$defs/request"
9
+ },
10
+ {
11
+ "$ref": "#/$defs/report"
12
+ },
13
+ {
14
+ "$ref": "#/$defs/stopRequest"
15
+ }
16
+ ],
17
+ "$defs": {
18
+ "runId": {
19
+ "description": "run- and a 26 character ULID in Crockford base32",
20
+ "type": "string",
21
+ "pattern": "^run-[0-7][0-9A-HJKMNP-TV-Z]{25}$"
22
+ },
23
+ "request": {
24
+ "description": "A request for a run. It carries no credential: a property named like a token, a secret or a password is refused.",
25
+ "type": "object",
26
+ "additionalProperties": false,
27
+ "required": [
28
+ "version",
29
+ "runId",
30
+ "kind",
31
+ "refs",
32
+ "brief",
33
+ "requestedBy",
34
+ "requestedAt"
35
+ ],
36
+ "propertyNames": {
37
+ "not": {
38
+ "pattern": "(?:[tT][oO][kK][eE][nN]|[sS][eE][cC][rR][eE][tT]|[pP][aA][sS][sS][wW]|[cC][rR][eE][dD][eE][nN][tT][iI][aA][lL]|[aA][pP][iI][_-]?[kK][eE][yY]|[aA][cC][cC][eE][sS][sS][_-]?[kK][eE][yY]|[pP][rR][iI][vV][aA][tT][eE][_-]?[kK][eE][yY]|[sS][sS][hH][_-]?[kK][eE][yY]|[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]|[bB][eE][aA][rR][eE][rR]|[cC][oO][oO][kK][iI][eE])"
39
+ }
40
+ },
41
+ "properties": {
42
+ "version": {
43
+ "const": 1
44
+ },
45
+ "runId": {
46
+ "$ref": "#/$defs/runId"
47
+ },
48
+ "kind": {
49
+ "enum": [
50
+ "code",
51
+ "summary",
52
+ "brief",
53
+ "proposal"
54
+ ]
55
+ },
56
+ "profile": {
57
+ "type": "string",
58
+ "maxLength": 64,
59
+ "not": {
60
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
61
+ },
62
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
63
+ "description": "A name the runner maps to a model, a machine and a budget. When the developer configured a list, only those names."
64
+ },
65
+ "refs": {
66
+ "type": "object",
67
+ "additionalProperties": false,
68
+ "properties": {
69
+ "issue": {
70
+ "type": "integer",
71
+ "minimum": 1,
72
+ "maximum": 2147483647
73
+ },
74
+ "proposal": {
75
+ "type": "string",
76
+ "maxLength": 120,
77
+ "not": {
78
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
79
+ },
80
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$"
81
+ },
82
+ "pull": {
83
+ "type": "integer",
84
+ "minimum": 1,
85
+ "maximum": 2147483647,
86
+ "description": "The spec pull request that holds the proposal; only with proposal."
87
+ },
88
+ "thread": {
89
+ "type": "integer",
90
+ "minimum": 1,
91
+ "maximum": 2147483647
92
+ }
93
+ },
94
+ "anyOf": [
95
+ {
96
+ "required": [
97
+ "issue"
98
+ ]
99
+ },
100
+ {
101
+ "required": [
102
+ "proposal"
103
+ ]
104
+ },
105
+ {
106
+ "required": [
107
+ "thread"
108
+ ]
109
+ }
110
+ ],
111
+ "dependentRequired": {
112
+ "pull": [
113
+ "proposal"
114
+ ]
115
+ }
116
+ },
117
+ "branch": {
118
+ "type": "string",
119
+ "maxLength": 200,
120
+ "not": {
121
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
122
+ },
123
+ "minLength": 1,
124
+ "description": "A hint: a safe git ref name.",
125
+ "allOf": [
126
+ {
127
+ "pattern": "^[A-Za-z0-9._/-]+$"
128
+ },
129
+ {
130
+ "not": {
131
+ "pattern": "^-|^/|/$|//|\\.\\.|\\.$|(^|/)\\.|\\.lock(/|$)"
132
+ }
133
+ }
134
+ ]
135
+ },
136
+ "brief": {
137
+ "type": "string",
138
+ "maxLength": 20000,
139
+ "not": {
140
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
141
+ }
142
+ },
143
+ "limits": {
144
+ "type": "object",
145
+ "additionalProperties": false,
146
+ "properties": {
147
+ "minutes": {
148
+ "type": "number",
149
+ "exclusiveMinimum": 0
150
+ },
151
+ "turns": {
152
+ "type": "integer",
153
+ "exclusiveMinimum": 0
154
+ },
155
+ "cost": {
156
+ "type": "number",
157
+ "exclusiveMinimum": 0
158
+ }
159
+ }
160
+ },
161
+ "nodes": {
162
+ "type": "array",
163
+ "maxItems": 100,
164
+ "items": {
165
+ "type": "string",
166
+ "maxLength": 200,
167
+ "not": {
168
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
169
+ },
170
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$"
171
+ }
172
+ },
173
+ "requestedBy": {
174
+ "type": "string",
175
+ "maxLength": 40,
176
+ "not": {
177
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
178
+ },
179
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$"
180
+ },
181
+ "requestedAt": {
182
+ "type": "string",
183
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,3})?Z$"
184
+ }
185
+ }
186
+ },
187
+ "report": {
188
+ "description": "What a runner may say about a run it is doing.",
189
+ "type": "object",
190
+ "additionalProperties": false,
191
+ "required": [
192
+ "state"
193
+ ],
194
+ "properties": {
195
+ "state": {
196
+ "enum": [
197
+ "claimed",
198
+ "working",
199
+ "done",
200
+ "failed",
201
+ "stopped"
202
+ ]
203
+ },
204
+ "note": {
205
+ "type": "string",
206
+ "maxLength": 4000,
207
+ "not": {
208
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
209
+ }
210
+ },
211
+ "url": {
212
+ "type": "string",
213
+ "maxLength": 2000,
214
+ "not": {
215
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
216
+ },
217
+ "pattern": "^https?://[^\\s/@?#]+([/?#]\\S*)?$",
218
+ "description": "Where the runner keeps its logs.",
219
+ "allOf": [
220
+ {
221
+ "not": {
222
+ "pattern": "[?&#;](?:[^\\s=&#;]*(?:[tT][oO][kK][eE][nN]|[sS][eE][cC][rR][eE][tT]|[pP][aA][sS][sS][wW]|[kK][eE][yY]|[sS][iI][gG][nN][aA][tT][uU][rR][eE])[^\\s=&#;]*|[sS][iI][gG])="
223
+ }
224
+ }
225
+ ]
226
+ },
227
+ "tokens": {
228
+ "type": "integer",
229
+ "minimum": 0,
230
+ "maximum": 9007199254740991
231
+ },
232
+ "cost": {
233
+ "type": "number",
234
+ "minimum": 0
235
+ }
236
+ }
237
+ },
238
+ "stopRequest": {
239
+ "description": "cornfield asking a runner to stop. The runner decides what stopping means.",
240
+ "type": "object",
241
+ "additionalProperties": false,
242
+ "required": [
243
+ "state"
244
+ ],
245
+ "properties": {
246
+ "state": {
247
+ "const": "stop-requested"
248
+ },
249
+ "note": {
250
+ "type": "string",
251
+ "maxLength": 4000,
252
+ "not": {
253
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+ }
package/core/config.js CHANGED
@@ -23,7 +23,8 @@
23
23
  * personas: { roles: null }, // the roles a persona may have (null: any)
24
24
  * releaseNotes: { breakingLabel: 'breaking' }, // the pull request label that lists a change under "Breaking" in the release notes
25
25
  * hooks: [], // [{ url, events }]: signed JSON events posted to outside code (docs/config.md#hooks); the secret is env only
26
- * hookPoll: { specs: '5m' }, // how often cornfield looks for ready specs on its own, for the spec.ready hook; false turns it off (docs/config.md#hook-poll)
26
+ * hookPoll: { specs: '5m' }, // how often cornfield looks for ready specs and thread activity on its own, for the spec.ready and thread.* hooks; false turns it off (docs/config.md#hook-poll)
27
+ * threads: { label: 'idea' }, // an idea is an issue with this label, its thread the issue's comments; false turns threads off (docs/threads.md)
27
28
  * links: [], // [{ label, href }] shown in the explorer's header
28
29
  * proxy: {}, // { '/api/': 'http://127.0.0.1:3245' }: same-origin paths for a live source
29
30
  * importMap: {}, // bare imports a repo-local browser module needs: { name: '/repo/path.js' }
@@ -66,7 +67,12 @@ export const FORGE_TYPES = ['github', 'forgejo'];
66
67
  /** How a pull request cornfield lands is merged (`forge.mergeMethod`): the way the repo lands its pull requests */
67
68
  export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
68
69
  /** The events a hook may ask for (`hooks[].events`), and what each one means: docs/config.md#hooks */
69
- export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring']);
70
+ export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring', 'thread.opened', 'thread.message', 'thread.linked']);
71
+ /** The events that come from looking at threads (server/threads.js): a poll that wants any of them looks at threads too */
72
+ export const THREAD_EVENTS = Object.freeze(['thread.opened', 'thread.message', 'thread.linked']);
73
+ /** The keys of `threads` when it is an object, and the label's longest length */
74
+ export const THREADS_KEYS = Object.freeze(['label']);
75
+ export const MAX_LABEL = 50;
70
76
  /** The most hooks a config lists */
71
77
  export const MAX_HOOKS = 20;
72
78
  /** The bounds of `hookPoll.specs` in milliseconds: not under a minute (the forge is not hammered), not over an hour */
@@ -85,14 +91,26 @@ export function parseDuration(v) {
85
91
  return m ? Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000 }[m[2]] : null;
86
92
  }
87
93
 
94
+ /** Whether some hook subscribes to any of `events` (by name, or with '*') */
95
+ export function hookWants(config, events) {
96
+ return Array.isArray(config.hooks) && config.hooks.some((h) => h.events === '*' || (Array.isArray(h.events) && h.events.some((e) => events.includes(e))));
97
+ }
98
+
99
+ /** Whether threads are on: `threads: false` turns them off, anything else (the default included) leaves them on */
100
+ export const threadsOn = (config) => config.threads !== false;
101
+
102
+ /** The label that makes an issue an idea, or null when threads are off */
103
+ export const ideaLabel = (config) => (threadsOn(config) ? (config.threads && config.threads.label) || DEFAULTS.threads.label : null);
104
+
88
105
  /**
89
- * How often cornfield looks at the open spec PRs by itself, in milliseconds, or null when it does not: when `hookPoll.specs`
90
- * is false, or no hook subscribes to `spec.ready` (by name or with '*').
91
- * @param {{ hooks?: { events: string[] | '*' }[], hookPoll?: { specs?: string | false } | false }} config a loaded config
106
+ * How often cornfield looks by itself, in milliseconds, or null when it does not: when `hookPoll.specs` is false, or no
107
+ * hook subscribes to something the look finds (`spec.ready`, or a `thread.*` event while threads are on), by name or with '*'.
108
+ * One look covers both: the open spec PRs and the threads (server/spec-poll.js).
109
+ * @param {{ hooks?: { events: string[] | '*' }[], hookPoll?: { specs?: string | false } | false, threads?: false | { label?: string } }} config a loaded config
92
110
  * @returns {number | null}
93
111
  */
94
112
  export function specPollMs(config) {
95
- const wanted = Array.isArray(config.hooks) && config.hooks.some((h) => h.events === '*' || (Array.isArray(h.events) && h.events.includes('spec.ready')));
113
+ const wanted = hookWants(config, ['spec.ready']) || (threadsOn(config) && hookWants(config, THREAD_EVENTS));
96
114
  const every = config.hookPoll === false ? false : config.hookPoll ? config.hookPoll.specs : DEFAULTS.hookPoll.specs;
97
115
  return wanted && every !== false ? parseDuration(every ?? DEFAULTS.hookPoll.specs) : null;
98
116
  }
@@ -115,6 +133,7 @@ export const DEFAULTS = Object.freeze({
115
133
  agents: { authors: [], labels: [], branchPrefixes: [], bots: true },
116
134
  hooks: [],
117
135
  hookPoll: { specs: '5m' },
136
+ threads: { label: 'idea' },
118
137
  links: [],
119
138
  proxy: {},
120
139
  importMap: {},
@@ -241,6 +260,14 @@ export function configProblems(c) {
241
260
  }
242
261
  }
243
262
  }
263
+ if (c.threads !== undefined && c.threads !== false) {
264
+ if (!isObject(c.threads)) out.push('"threads" is { label: "idea" }, or false to turn threads off.');
265
+ else {
266
+ for (const k of Object.keys(c.threads)) if (!THREADS_KEYS.includes(k)) out.push(`threads.${k}: the key is ${THREADS_KEYS.join(', ')}.`);
267
+ const l = c.threads.label;
268
+ if (l !== undefined && (!isText(l) || l.includes(',') || l.trim() !== l || l.length > MAX_LABEL)) out.push(`threads.label is the name of the label that makes an issue an idea, like "idea": text of at most ${MAX_LABEL} characters, no comma, no space at either end.`);
269
+ }
270
+ }
244
271
  if (c.releaseNotes !== undefined) {
245
272
  if (!isObject(c.releaseNotes)) out.push('"releaseNotes" is { breakingLabel }.');
246
273
  else if (c.releaseNotes.breakingLabel !== undefined && (!isText(c.releaseNotes.breakingLabel) || c.releaseNotes.breakingLabel.includes(','))) out.push('releaseNotes.breakingLabel is a label\'s name, like "breaking".');
@@ -323,7 +350,7 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
323
350
  if (problems.length) throw new Error(`${path.relative(absRoot, configFile) || CONFIG_FILE}:\n ${problems.join('\n ')}`);
324
351
  }
325
352
  const config = {
326
- ...structuredClone({ ...DEFAULTS, sources: { ...DEFAULTS.sources }, personas: { ...DEFAULTS.personas }, cloud: { ...DEFAULTS.cloud }, agents: { ...DEFAULTS.agents }, releaseNotes: { ...DEFAULTS.releaseNotes } }),
353
+ ...structuredClone({ ...DEFAULTS, sources: { ...DEFAULTS.sources }, personas: { ...DEFAULTS.personas }, cloud: { ...DEFAULTS.cloud }, agents: { ...DEFAULTS.agents }, releaseNotes: { ...DEFAULTS.releaseNotes }, threads: { ...DEFAULTS.threads } }),
327
354
  ...raw,
328
355
  sources: { ...DEFAULTS.sources, ...(raw.sources || {}) },
329
356
  personas: { ...DEFAULTS.personas, ...(raw.personas || {}) },
@@ -332,6 +359,8 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
332
359
  deploys: { ...DEFAULTS.deploys, ...(raw.deploys || {}) },
333
360
  agents: { ...DEFAULTS.agents, ...(raw.agents || {}) },
334
361
  };
362
+ // `threads: false` stays false; an object is read over the default
363
+ config.threads = raw.threads === false ? false : { ...DEFAULTS.threads, ...(raw.threads || {}) };
335
364
  // `hookPoll: false` is `{ specs: false }`; an object is read over the default
336
365
  config.hookPoll = raw.hookPoll === false ? { specs: false } : { ...DEFAULTS.hookPoll, ...(raw.hookPoll || {}) };
337
366
  // `proposals` is the folder to everything downstream, whichever way the file wrote it; the options are read from `approveLands`
package/core/defuse.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @module core/defuse
3
+ *
4
+ * Breaking the mentions and references in text cornfield posts on the forge for someone else: `@user`, `@org/team`,
5
+ * `#1`, `owner/repo#1`, `GH-1`, and the links the forge would make of `https://…` or `www.…`. A zero-width space after
6
+ * the character keeps the text as it reads and stops it notifying anyone or linking anything. The pipeline's issues
7
+ * (server/pipeline.js), a review that came through the review API (server/reviews.js) and the agent contract
8
+ * (core/agent-contract.js) use it. It imports nothing, so Node and the browser can both load it.
9
+ */
10
+
11
+ /** A zero-width space: it ends a mention or a reference without showing */
12
+ export const ZW = '​';
13
+
14
+ /** Text with every mention, reference and auto-link broken (see the module's head) */
15
+ export function defuse(text) {
16
+ return String(text).replace(/[@#]/g, (c) => `${c}${ZW}`).replace(/\b(GH)-(?=\d)/gi, `$1${ZW}-`).replace(/:\/\//g, `:${ZW}//`).replace(/\b(www)\./gi, `$1${ZW}.`);
17
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * @module core/mentions
3
+ *
4
+ * How a thread points at the map (spec 0005, D7). Pure: no I/O, no DOM, no Node built-ins, so the server (a hook's
5
+ * payload) and the explorer (the lights, a node's "Mentioned in") load the same file.
6
+ *
7
+ * - **Mentions.** People write `[[core-server-auth]]` (a node's id) or `[[server/auth.js]]` (a repo path) in prose.
8
+ * `parseMentions` reads them outside code: a fenced block and an inline code span are text about code, not links.
9
+ * - **A block.** Tools and agents may write a fenced block for precise links:
10
+ *
11
+ * ```cornfield-thread
12
+ * { "nodes": ["core-server-auth", "server/hooks.js"], "proposal": "0013-meals" }
13
+ * ```
14
+ *
15
+ * `nodes` are read like mentions (an id or a path); `proposal` is a proposal's slug. A block that is not that shape
16
+ * is ignored, and prose is read as before.
17
+ * - **Resolving.** `resolveMention` answers a reference in this order: a node's id; a node whose `file` is exactly
18
+ * that path; the node(s) that own the path (the deepest `file` that is the path or a folder above it, as a file
19
+ * lights its node for a pull request, `nodesForFile`). A reference that is none of these resolves to nothing and
20
+ * stays plain text, which reads sensibly on the forge without cornfield.
21
+ * - **Caps.** At most `MAX_MENTIONS` distinct references are read from one text, and a reference is at most
22
+ * `MAX_REF` characters, so a comment cannot make the map light up everything or cost a lot to read.
23
+ * - **Links.** `threadOfPull` reads the thread a spec PR names: `Thread: #n` or `Closes #n` (also Fixes and Resolves).
24
+ */
25
+
26
+ /** The most distinct references read from one text, and the longest one */
27
+ export const MAX_MENTIONS = 50;
28
+ export const MAX_REF = 200;
29
+ /** The most a `cornfield-thread` block may name */
30
+ export const MAX_BLOCK_NODES = 50;
31
+ /** The fence's info string that marks a block of links */
32
+ export const THREAD_BLOCK = 'cornfield-thread';
33
+
34
+ const SLUG = /^[a-z0-9][a-z0-9._-]{0,99}$/i;
35
+
36
+ const clean = (p) => String(p).replace(/^\.\//, '').replace(/\/+$/, '');
37
+
38
+ /**
39
+ * The node(s) whose `file` holds a path: the node's `file` is the path itself or a folder above it, and the deepest
40
+ * (longest) `file` wins. A file that holds several nodes (a page and what renders it) lights all of them. (The explorer's
41
+ * forge panel lights a pull request's files with this same rule.)
42
+ * @param {string} path a repo-relative path
43
+ * @param {{ id: string, file?: string }[]} nodes
44
+ * @returns {object[]} the nodes, [] when none holds it
45
+ */
46
+ export function nodesForFile(path, nodes) {
47
+ const p = clean(path);
48
+ let best = -1;
49
+ let out = [];
50
+ for (const node of nodes) {
51
+ if (typeof node.file !== 'string' || node.file === '') continue;
52
+ const file = clean(node.file);
53
+ if (p !== file && !p.startsWith(`${file}/`)) continue;
54
+ if (file.length > best) { best = file.length; out = [node]; } else if (file.length === best) out.push(node);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Splits text into prose and code: `prose` is the text with every fenced block and inline code span blanked (same length,
61
+ * so offsets hold), `fences` are the fenced blocks as `{ info, body }`.
62
+ * @param {string} text
63
+ * @returns {{ prose: string, fences: { info: string, body: string }[] }}
64
+ */
65
+ export function scan(text) {
66
+ const src = String(text ?? '');
67
+ const lines = src.split('\n');
68
+ const fences = [];
69
+ const out = [];
70
+ let open = null; // { char, len, info, body: [] }
71
+ for (const line of lines) {
72
+ if (open) {
73
+ const close = new RegExp(`^ {0,3}${open.char}{${open.len},}[ \\t]*\\r?$`).test(line);
74
+ if (close) { fences.push({ info: open.info, body: open.body.join('\n') }); open = null; } else open.body.push(line.replace(/\r$/, ''));
75
+ out.push(' '.repeat(line.length));
76
+ continue;
77
+ }
78
+ const m = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\r]*)$/.exec(line);
79
+ // An info string of a backtick fence cannot hold a backtick (then it is an inline span, not a fence)
80
+ if (m && !(m[1][0] === '`' && m[2].includes('`'))) {
81
+ open = { char: m[1][0], len: m[1].length, info: m[2].trim(), body: [] };
82
+ out.push(' '.repeat(line.length));
83
+ continue;
84
+ }
85
+ out.push(line);
86
+ }
87
+ // A fence left open runs to the end of the text, as the forge's Markdown reads it
88
+ if (open) fences.push({ info: open.info, body: open.body.join('\n') });
89
+ return { prose: blankSpans(out.join('\n')), fences };
90
+ }
91
+
92
+ /** Inline code spans blanked: a run of backticks closes at the next run of the same length */
93
+ function blankSpans(text) {
94
+ let out = '';
95
+ let i = 0;
96
+ while (i < text.length) {
97
+ if (text[i] !== '`') { out += text[i++]; continue; }
98
+ let n = 0;
99
+ while (text[i + n] === '`') n++;
100
+ let j = i + n;
101
+ let end = -1;
102
+ while (j < text.length) {
103
+ if (text[j] !== '`') { j++; continue; }
104
+ let m = 0;
105
+ while (text[j + m] === '`') m++;
106
+ if (m === n) { end = j + m; break; }
107
+ j += m;
108
+ }
109
+ if (end === -1) { out += text.slice(i, i + n); i += n; continue; }
110
+ out += text.slice(i, end).replace(/[^\n]/g, ' ');
111
+ i = end;
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /** A reference as written, made comparable: no `./`, no trailing slash, no `#L10` or `:10` line suffix; or null when it is not one */
117
+ export function normalizeRef(raw) {
118
+ let ref = String(raw ?? '').trim();
119
+ if (ref === '' || ref.length > MAX_REF || /[\0\n\r]/.test(ref)) return null;
120
+ ref = ref.replace(/#.*$/, '').replace(/:\d+(?:-\d+)?$/, '');
121
+ ref = clean(ref).trim();
122
+ if (ref === '' || ref.startsWith('/') || ref.split('/').some((p) => p === '..' || p === '.')) return null;
123
+ return ref;
124
+ }
125
+
126
+ /**
127
+ * The `cornfield-thread` block of a text, or null: the first well-formed one wins.
128
+ * @param {{ info: string, body: string }[]} fences
129
+ * @returns {{ nodes: string[], proposal: string | null } | null}
130
+ */
131
+ function blockOf(fences) {
132
+ for (const f of fences) {
133
+ if (f.info.split(/\s+/)[0] !== THREAD_BLOCK) continue;
134
+ let json;
135
+ try { json = JSON.parse(f.body); } catch { continue; }
136
+ if (json === null || typeof json !== 'object' || Array.isArray(json)) continue;
137
+ const nodes = Array.isArray(json.nodes) ? json.nodes.map((n) => (typeof n === 'string' ? normalizeRef(n) : null)).filter((r) => r !== null).slice(0, MAX_BLOCK_NODES) : [];
138
+ const proposal = typeof json.proposal === 'string' && SLUG.test(json.proposal) ? json.proposal : null;
139
+ if (!nodes.length && !proposal) continue;
140
+ return { nodes: [...new Set(nodes)], proposal };
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * What a text points at, before the map is asked: the `[[…]]` references outside code (each once, in order, at most
147
+ * `MAX_MENTIONS`), and the `cornfield-thread` block when there is one.
148
+ * @param {string} text
149
+ * @returns {{ refs: string[], block: { nodes: string[], proposal: string | null } | null, truncated: boolean }}
150
+ */
151
+ export function parseMentions(text) {
152
+ const { prose, fences } = scan(text);
153
+ const refs = [];
154
+ let truncated = false;
155
+ for (const m of prose.matchAll(/(?<!\\)\[\[([^[\]\n]{1,400})\]\]/g)) {
156
+ const ref = normalizeRef(m[1]);
157
+ if (ref === null || refs.includes(ref)) continue;
158
+ if (refs.length >= MAX_MENTIONS) { truncated = true; break; }
159
+ refs.push(ref);
160
+ }
161
+ return { refs, block: blockOf(fences), truncated };
162
+ }
163
+
164
+ /**
165
+ * The nodes one reference stands for, in order of how sure it is: the id; else nodes whose `file` is exactly the path;
166
+ * else the nodes that own the path.
167
+ * @param {string} ref
168
+ * @param {{ id: string, file?: string }[]} nodes
169
+ * @returns {object[]}
170
+ */
171
+ export function resolveMention(ref, nodes) {
172
+ const r = normalizeRef(ref);
173
+ if (r === null) return [];
174
+ const byId = nodes.filter((n) => n.id === r);
175
+ if (byId.length) return byId;
176
+ const exact = nodes.filter((n) => typeof n.file === 'string' && n.file !== '' && clean(n.file) === r);
177
+ if (exact.length) return exact;
178
+ return nodesForFile(r, nodes);
179
+ }
180
+
181
+ /**
182
+ * References resolved against a map.
183
+ * @param {string[]} refs references as `parseMentions` gives them (or a block's `nodes`)
184
+ * @param {{ id: string, file?: string }[]} nodes
185
+ * @returns {{ resolved: { ref: string, ids: string[] }[], ids: string[], unresolved: string[] }} `ids`: every node once, in
186
+ * order; `unresolved`: the references that are on no node (they stay text)
187
+ */
188
+ export function resolveMentions(refs, nodes) {
189
+ const resolved = [];
190
+ const unresolved = [];
191
+ const seen = new Set();
192
+ const list = Array.isArray(refs) ? refs.slice(0, MAX_MENTIONS + MAX_BLOCK_NODES) : [];
193
+ for (const ref of list) {
194
+ const found = resolveMention(ref, nodes);
195
+ if (!found.length) { unresolved.push(ref); continue; }
196
+ const ids = found.map((n) => n.id);
197
+ resolved.push({ ref, ids });
198
+ for (const id of ids) seen.add(id);
199
+ }
200
+ return { resolved, ids: [...seen], unresolved };
201
+ }
202
+
203
+ /**
204
+ * Everything a set of texts (a thread's messages) points at, before the map is asked: the references of every text and of
205
+ * its blocks, each once, and the proposal a block names (the first).
206
+ * @param {string[]} texts
207
+ * @returns {{ refs: string[], proposal: string | null, truncated: boolean }}
208
+ */
209
+ export function linksOf(texts) {
210
+ const refs = [];
211
+ let proposal = null;
212
+ let truncated = false;
213
+ const cap = MAX_MENTIONS * 2;
214
+ for (const text of texts) {
215
+ const got = parseMentions(text);
216
+ truncated ||= got.truncated;
217
+ for (const ref of [...got.refs, ...(got.block ? got.block.nodes : [])]) {
218
+ if (refs.includes(ref)) continue;
219
+ if (refs.length >= cap) { truncated = true; break; }
220
+ refs.push(ref);
221
+ }
222
+ if (proposal === null && got.block && got.block.proposal) proposal = got.block.proposal;
223
+ }
224
+ return { refs, proposal, truncated };
225
+ }
226
+
227
+ /**
228
+ * The thread a spec PR's text names: `Thread: #n`, or `Closes #n` (also Fixes and Resolves, and their other tenses), outside
229
+ * code. The first one named. An issue in another repo (`owner/name#n`) is not this repo's thread.
230
+ * @param {string} body
231
+ * @returns {number | null}
232
+ */
233
+ export function threadOfPull(body) {
234
+ const { prose } = scan(body);
235
+ // `(?:\s*:)?\s+` and not `\s*:?\s+`: two whitespace runs side by side backtrack against each other, which made a long
236
+ // run of spaces after "Closes" (or a code block, blanked to spaces) quadratic; this way each run is read once
237
+ const m = /(?:^|[\s(])(?:thread\s*:|(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?:\s*:)?)\s+#([1-9]\d{0,8})\b/i.exec(prose);
238
+ return m ? Number(m[1]) : null;
239
+ }
@@ -913,3 +913,12 @@ a.hx-forge-row:hover, button.hx-forge-row:hover { background: var(--hx-control-h
913
913
  :root[data-theme="light"] .hx-forge-tab[aria-pressed="true"] { box-shadow: 0 1px 3px rgba(15, 23, 42, 0.25); }
914
914
  :root[data-theme="light"] .hx-forge { box-shadow: 10px 0 30px -12px rgba(15, 23, 42, 0.18); }
915
915
  :root[data-theme="light"] .hx-forge:not(.forge-open) { box-shadow: none; }
916
+
917
+ /* Threads (forge-panel.js, docs/threads.md): messages as written, read-only; a node's "Mentioned in" opens one */
918
+ .hx-thread-msgs { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
919
+ .hx-thread-msg { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px solid var(--hx-line); border-radius: 10px; background: var(--hx-control); }
920
+ .hx-thread-body { margin: 0; color: var(--hx-ink); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
921
+ .hx-thread-nodes { padding-left: 0; }
922
+ .hx-thread-compose { font-family: inherit; min-height: 8em; }
923
+ .hx-thread-open { display: block; max-width: 100%; text-align: left; overflow-wrap: anywhere; }
924
+ #forge-idea[hidden], #idea-form[hidden], #idea-message[hidden] { display: none; }