@popcorn245/cornfield 0.7.0 → 0.9.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 CHANGED
@@ -42,6 +42,7 @@ ssh -L 3350:127.0.0.1:3350 you@the-machine
42
42
  - **Derived, not drawn by hand.** `cornfield sync` runs the derivers named in your config and merges what they find into `topology.json`; hand-written parts (descriptions, flows, anything of a type no deriver owns) are kept. `cornfield validate` fails on drift, so CI keeps the map honest.
43
43
  - **Proposals.** `docs/specs/proposals/NNNN-slug.json` says what a change would add, change and remove, with scenarios. cornfield lists them from your checkout, from every git worktree in the configured worktrees folder and, with sign-in, from your open spec PRs, and shows Before / Diff / After, the scenario player, what-if edits, and Approve / Send back. Writing, reviewing and landing one in your repo: [docs/proposals.md](docs/proposals.md). The format and its validator: [core/proposal.js](core/proposal.js).
44
44
  - **Issues and pull requests.** With a forge in the config and sign-in, a panel of its pull requests and issues (GitHub; Forgejo behind the same interface), with filters. Select a pull request and the nodes its files are light up on the map, and a failing check lights the tests it names. A Deploys list reads the forge's workflow runs and lights what a selected deploy restarted, from a mapping in the config ([docs/deploys.md](docs/deploys.md)). A maintainer can land a green pull request, re-run a failed check, start a spec on an issue and open the build issue of an approved spec, each one button with a confirm step. A bottom sheet on a phone. A **Release notes** tab drafts the notes from the pull requests merged since the last tag, grouped by what they changed on the map, to copy or to post as a draft release (never published); `cornfield notes` prints the same Markdown. [docs/issues-and-prs.md](docs/issues-and-prs.md).
45
+ - **Threads.** The panel's Threads tab shows ideas (issues labelled `idea`) and spec PRs' conversations from the forge, read-only, and lights the nodes they mention with `[[node-id]]` or `[[repo/path]]`; a node's panel lists the threads that mention it. "New idea" opens an issue as you. Hooks tell outside code when a thread opens, gets a comment or is linked to the map. Nothing is kept in cornfield. [docs/threads.md](docs/threads.md).
45
46
  - **Agents, tracked.** The panel's Agents tab groups branches, spec PRs and PRs by the issue and the proposal they belong to, and marks the work agents made (by author, bot account, label or branch prefix, set in the config). Nothing is launched. [docs/agents.md](docs/agents.md).
46
47
  - **Personas, not people.** `topology.personas.json` holds archetypes (Admin, Member, Guest); scenarios act as them, never as a real person.
47
48
  - **The cloud you do not control.** `topology.cloud.json` describes the outside services; the cloud deriver's detectors (hosts in code, Dockerfiles, container images, CI workflows, a Caddyfile) keep it complete in both directions. "Cloudflare is down" plays as an outage: what breaks now, at the next deploy, at the next certificate renewal. [docs/cloud.md](docs/cloud.md).
@@ -118,7 +118,8 @@ export const CREDENTIAL_KEY_SOURCE = `(?:${['token', 'secret', 'passw', 'credent
118
118
  const EDGE = '(?<![A-Za-z0-9])';
119
119
 
120
120
  /**
121
- * A value shaped like a credential: a forge or cloud token, a JWT, a private key, a bearer header, a user and password in a link.
121
+ * A value shaped like a credential: a forge or cloud token, cornfield's own API tokens (`cf_rt_…` review, `cf_at_…` agent), a JWT, a
122
+ * private key, a bearer header, a user and password in a link.
122
123
  * Prose that only talks about tokens and passwords passes; `sk-` keys and bearer values need a digit in their first 41
123
124
  * characters, as real ones have, so a long hyphenated word after "Bearer" or "sk-" is not taken for one. Each alternative tries a
124
125
  * bounded stretch from each place it may start, so a 20000 character brief is read in linear time whatever it holds.
@@ -128,6 +129,7 @@ export const CREDENTIAL_VALUE_SOURCE = [
128
129
  `${EDGE}github_pat_[A-Za-z0-9_]{20,}`,
129
130
  `${EDGE}glpat-[A-Za-z0-9_-]{15,}`,
130
131
  `${EDGE}gto-[A-Za-z0-9]{20,}`,
132
+ `${EDGE}cf_(?:rt|at)_[A-Za-z0-9_-]{20,}`,
131
133
  `${EDGE}sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]`,
132
134
  `${EDGE}AKIA[0-9A-Z]{16}`,
133
135
  `${EDGE}AIza[0-9A-Za-z_-]{35}`,
@@ -624,7 +626,8 @@ export function runBlocksOf(comments, { trust, profiles } = /** @type {any} */ (
624
626
  const by = c.author?.login ?? null;
625
627
  const comment = { id: String(c.id), url: c.url ?? null };
626
628
  for (const r of read.refused) out.refused.push({ ...r, by, comment });
627
- const edited = c.updatedAt !== undefined && Date.parse(c.updatedAt) - Date.parse(c.createdAt) > EDIT_GRACE_MS;
629
+ // A time that does not parse is not taken for "never edited": only a comment the forge says it left alone counts
630
+ const edited = c.updatedAt !== undefined && !(Date.parse(c.updatedAt) - Date.parse(c.createdAt) <= EDIT_GRACE_MS);
628
631
  for (const b of read.blocks) {
629
632
  const found = { ...b, by, at: c.createdAt, comment };
630
633
  if (by === null || !trust(by, b)) out.ignored.push({ ...found, why: 'author' });
@@ -57,7 +57,7 @@
57
57
  "type": "string",
58
58
  "maxLength": 64,
59
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/@]+@"
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])cf_(?:rt|at)_[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
61
  },
62
62
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
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."
@@ -75,7 +75,7 @@
75
75
  "type": "string",
76
76
  "maxLength": 120,
77
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/@]+@"
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])cf_(?:rt|at)_[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
79
  },
80
80
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$"
81
81
  },
@@ -118,7 +118,7 @@
118
118
  "type": "string",
119
119
  "maxLength": 200,
120
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/@]+@"
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])cf_(?:rt|at)_[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
122
  },
123
123
  "minLength": 1,
124
124
  "description": "A hint: a safe git ref name.",
@@ -137,7 +137,7 @@
137
137
  "type": "string",
138
138
  "maxLength": 20000,
139
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/@]+@"
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])cf_(?:rt|at)_[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
141
  }
142
142
  },
143
143
  "limits": {
@@ -165,7 +165,7 @@
165
165
  "type": "string",
166
166
  "maxLength": 200,
167
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/@]+@"
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])cf_(?:rt|at)_[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
169
  },
170
170
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$"
171
171
  }
@@ -174,7 +174,7 @@
174
174
  "type": "string",
175
175
  "maxLength": 40,
176
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/@]+@"
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])cf_(?:rt|at)_[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
178
  },
179
179
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$"
180
180
  },
@@ -205,14 +205,14 @@
205
205
  "type": "string",
206
206
  "maxLength": 4000,
207
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/@]+@"
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])cf_(?:rt|at)_[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
209
  }
210
210
  },
211
211
  "url": {
212
212
  "type": "string",
213
213
  "maxLength": 2000,
214
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/@]+@"
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])cf_(?:rt|at)_[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
216
  },
217
217
  "pattern": "^https?://[^\\s/@?#]+([/?#]\\S*)?$",
218
218
  "description": "Where the runner keeps its logs.",
@@ -250,7 +250,7 @@
250
250
  "type": "string",
251
251
  "maxLength": 4000,
252
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/@]+@"
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])cf_(?:rt|at)_[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
254
  }
255
255
  }
256
256
  }
package/core/config.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * },
14
14
  * forge: { type: 'github', url: 'https://github.com', repo: 'owner/name' }, // or type 'forgejo'; mergeMethod: 'merge' | 'squash' | 'rebase'; baseBranch: 'main'
15
15
  * deploys: { workflows: ['*deploy*'], restarts: { 'Deploy web': ['stack-web'] } }, // which workflow runs are deploys, and what they restart (docs/deploys.md)
16
- * agents: { authors: [], labels: [], branchPrefixes: [], bots: true }, // what marks work as made by an agent (docs/agents.md)
16
+ * agents: { authors: [], labels: [], branchPrefixes: [], bots: true }, // what marks work as made by an agent (docs/agents.md); and how runs are asked for and followed: transport, maxOpen, profiles, runners, quietAfter, limits (docs/config.md#agents)
17
17
  * port: 3350,
18
18
  * host: '127.0.0.1', // loopback, unless sign-in is configured with an https publicUrl (server/bind.js)
19
19
  * publicUrl: null, // the origin people open it at; sign-in needs it (server/auth.js)
@@ -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' }
@@ -39,6 +40,7 @@ import fs from 'node:fs';
39
40
  import os from 'node:os';
40
41
  import path from 'node:path';
41
42
  import { pathToFileURL, fileURLToPath } from 'node:url';
43
+ import { PROFILE_SOURCE, MAX_NUMBER } from './agent-contract.js';
42
44
 
43
45
  export const CONFIG_FILE = 'cornfield.config.js';
44
46
  export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -66,7 +68,12 @@ export const FORGE_TYPES = ['github', 'forgejo'];
66
68
  /** How a pull request cornfield lands is merged (`forge.mergeMethod`): the way the repo lands its pull requests */
67
69
  export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
68
70
  /** 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']);
71
+ export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring', 'thread.opened', 'thread.message', 'thread.linked', 'agent.requested', 'run.claimed', 'run.state', 'run.stopRequested', 'run.finished']);
72
+ /** The events that come from looking at threads (server/threads.js): a poll that wants any of them looks at threads too */
73
+ export const THREAD_EVENTS = Object.freeze(['thread.opened', 'thread.message', 'thread.linked']);
74
+ /** The keys of `threads` when it is an object, and the label's longest length */
75
+ export const THREADS_KEYS = Object.freeze(['label']);
76
+ export const MAX_LABEL = 50;
70
77
  /** The most hooks a config lists */
71
78
  export const MAX_HOOKS = 20;
72
79
  /** The bounds of `hookPoll.specs` in milliseconds: not under a minute (the forge is not hammered), not over an hour */
@@ -85,18 +92,49 @@ export function parseDuration(v) {
85
92
  return m ? Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000 }[m[2]] : null;
86
93
  }
87
94
 
95
+ /** Whether some hook subscribes to any of `events` (by name, or with '*') */
96
+ export function hookWants(config, events) {
97
+ return Array.isArray(config.hooks) && config.hooks.some((h) => h.events === '*' || (Array.isArray(h.events) && h.events.some((e) => events.includes(e))));
98
+ }
99
+
100
+ /** Whether threads are on: `threads: false` turns them off, anything else (the default included) leaves them on */
101
+ export const threadsOn = (config) => config.threads !== false;
102
+
103
+ /** The label that makes an issue an idea, or null when threads are off */
104
+ export const ideaLabel = (config) => (threadsOn(config) ? (config.threads && config.threads.label) || DEFAULTS.threads.label : null);
105
+
88
106
  /**
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
107
+ * How often cornfield looks by itself, in milliseconds, or null when it does not: when `hookPoll.specs` is false, or no
108
+ * hook subscribes to something the look finds (`spec.ready`, or a `thread.*` event while threads are on), by name or with '*'.
109
+ * One look covers both: the open spec PRs and the threads (server/spec-poll.js).
110
+ * @param {{ hooks?: { events: string[] | '*' }[], hookPoll?: { specs?: string | false } | false, threads?: false | { label?: string } }} config a loaded config
92
111
  * @returns {number | null}
93
112
  */
94
113
  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')));
114
+ const wanted = hookWants(config, ['spec.ready']) || (threadsOn(config) && hookWants(config, THREAD_EVENTS));
96
115
  const every = config.hookPoll === false ? false : config.hookPoll ? config.hookPoll.specs : DEFAULTS.hookPoll.specs;
97
116
  return wanted && every !== false ? parseDuration(every ?? DEFAULTS.hookPoll.specs) : null;
98
117
  }
99
118
 
119
+ /** How a request reaches a runner: the forge only for now (spec 0005 D2; push and pull come with #81) */
120
+ export const AGENT_TRANSPORTS = Object.freeze(['forge']);
121
+ /** `agents.maxOpen`: the most open runs per repo cornfield will ask for, by default and at most */
122
+ export const DEFAULT_MAX_OPEN = 3;
123
+ export const MAX_OPEN_CEILING = 100;
124
+ /** `agents.quietAfter`: how long a run may show no report, push or pull request activity before it is quiet; its bounds in milliseconds */
125
+ export const DEFAULT_QUIET_AFTER = '24h';
126
+ export const QUIET_MIN_MS = 5 * 60_000;
127
+ export const QUIET_MAX_MS = 30 * 24 * 3_600_000;
128
+ /** The most profiles and runners a config lists */
129
+ export const MAX_PROFILES = 20;
130
+ export const MAX_RUNNERS = 50;
131
+
132
+ /**
133
+ * How long a run may be silent before it is quiet, in milliseconds: `agents.quietAfter`, '24h' unless set.
134
+ * @param {{ agents?: { quietAfter?: string } }} config a loaded config
135
+ */
136
+ export const quietAfterMs = (config) => parseDuration(config.agents?.quietAfter ?? DEFAULT_QUIET_AFTER) ?? parseDuration(DEFAULT_QUIET_AFTER);
137
+
100
138
  export const DEFAULTS = Object.freeze({
101
139
  title: null,
102
140
  derivers: ['starter', 'personas', 'cloud'],
@@ -112,9 +150,10 @@ export const DEFAULTS = Object.freeze({
112
150
  personas: { roles: null, parent: 'root' },
113
151
  cloud: { parent: 'root' },
114
152
  releaseNotes: { breakingLabel: 'breaking' },
115
- agents: { authors: [], labels: [], branchPrefixes: [], bots: true },
153
+ agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {} },
116
154
  hooks: [],
117
155
  hookPoll: { specs: '5m' },
156
+ threads: { label: 'idea' },
118
157
  links: [],
119
158
  proxy: {},
120
159
  importMap: {},
@@ -124,7 +163,8 @@ export const DEFAULTS = Object.freeze({
124
163
  const KEYS = Object.keys(DEFAULTS);
125
164
  /** The keys of `proposals` when it is an object: the folder, and whether an approval also lands the spec PR (opt-in, off unless true) */
126
165
  export const PROPOSALS_KEYS = Object.freeze(['dir', 'approveLands']);
127
- const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots'];
166
+ const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits'];
167
+ const PROFILE_NAME = new RegExp(PROFILE_SOURCE);
128
168
  const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
129
169
  const isText = (v) => typeof v === 'string' && v.trim() !== '';
130
170
  /** A path inside the repo: relative, forward slashes, no `..` segment */
@@ -184,7 +224,7 @@ export function configProblems(c) {
184
224
  }
185
225
  }
186
226
  if (c.agents !== undefined) {
187
- if (!isObject(c.agents)) out.push('"agents" is { authors, labels, branchPrefixes, bots }.');
227
+ if (!isObject(c.agents)) out.push(`"agents" is { ${AGENT_KEYS.join(', ')} }.`);
188
228
  else {
189
229
  for (const k of Object.keys(c.agents)) if (!AGENT_KEYS.includes(k)) out.push(`agents.${k}: the keys are ${AGENT_KEYS.join(', ')}.`);
190
230
  for (const k of ['authors', 'labels', 'branchPrefixes']) {
@@ -196,6 +236,7 @@ export function configProblems(c) {
196
236
  }
197
237
  }
198
238
  if (c.agents.bots !== undefined && typeof c.agents.bots !== 'boolean') out.push('agents.bots is true or false: whether a bot account ("[bot]" or the forge\'s Bot type) counts as an agent.');
239
+ out.push(...agentRunProblems(c.agents));
199
240
  }
200
241
  }
201
242
  if (c.deploys !== undefined && c.deploys !== null) {
@@ -241,6 +282,14 @@ export function configProblems(c) {
241
282
  }
242
283
  }
243
284
  }
285
+ if (c.threads !== undefined && c.threads !== false) {
286
+ if (!isObject(c.threads)) out.push('"threads" is { label: "idea" }, or false to turn threads off.');
287
+ else {
288
+ for (const k of Object.keys(c.threads)) if (!THREADS_KEYS.includes(k)) out.push(`threads.${k}: the key is ${THREADS_KEYS.join(', ')}.`);
289
+ const l = c.threads.label;
290
+ 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.`);
291
+ }
292
+ }
244
293
  if (c.releaseNotes !== undefined) {
245
294
  if (!isObject(c.releaseNotes)) out.push('"releaseNotes" is { breakingLabel }.');
246
295
  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".');
@@ -279,6 +328,38 @@ export function configProblems(c) {
279
328
  return out;
280
329
  }
281
330
 
331
+ /** What is wrong with the keys of `agents` that ask for and follow runs (spec 0005), in words */
332
+ function agentRunProblems(a) {
333
+ const out = [];
334
+ if (a.transport !== undefined && !AGENT_TRANSPORTS.includes(a.transport)) out.push(`agents.transport is ${AGENT_TRANSPORTS.map((t) => JSON.stringify(t)).join(' or ')}: a request is posted on the forge (push and pull come later).`);
335
+ if (a.maxOpen !== undefined && !(Number.isInteger(a.maxOpen) && a.maxOpen >= 1 && a.maxOpen <= MAX_OPEN_CEILING)) out.push(`agents.maxOpen is a whole number from 1 to ${MAX_OPEN_CEILING}: the most open runs cornfield asks for at once (${DEFAULT_MAX_OPEN} unless set).`);
336
+ if (a.profiles !== undefined) {
337
+ if (!Array.isArray(a.profiles) || a.profiles.length > MAX_PROFILES || !a.profiles.every((p) => typeof p === 'string' && PROFILE_NAME.test(p)) || new Set(a.profiles).size !== a.profiles.length) {
338
+ out.push(`agents.profiles is a list of at most ${MAX_PROFILES} different names ("claude", "local-qwen"): letters, digits and . _ -, at most 64 characters each. The runner maps each to a model; cornfield only knows the name.`);
339
+ }
340
+ }
341
+ if (a.runners !== undefined) {
342
+ if (!Array.isArray(a.runners) || a.runners.length > MAX_RUNNERS || !a.runners.every((r) => isText(r) && r === r.trim() && !/[\s,]/.test(r))) {
343
+ out.push(`agents.runners is a list of at most ${MAX_RUNNERS} forge logins ("claude-bot"), the runners' own accounts whose reports count besides a maintainer's.`);
344
+ }
345
+ }
346
+ if (a.quietAfter !== undefined) {
347
+ const ms = parseDuration(a.quietAfter);
348
+ if (ms === null) out.push('agents.quietAfter is a duration like "90m", "24h" or "72h".');
349
+ else if (ms < QUIET_MIN_MS) out.push(`agents.quietAfter ${JSON.stringify(a.quietAfter)} is under the floor of 5m; use "5m" or more.`);
350
+ else if (ms > QUIET_MAX_MS) out.push(`agents.quietAfter ${JSON.stringify(a.quietAfter)} is over the ceiling of 30 days; use "720h" or less.`);
351
+ }
352
+ if (a.limits !== undefined) {
353
+ if (!isObject(a.limits)) out.push('agents.limits is { minutes?, turns?, cost? }: hints the runner may enforce.');
354
+ else {
355
+ for (const k of Object.keys(a.limits)) if (!['minutes', 'turns', 'cost'].includes(k)) out.push(`agents.limits.${k}: the keys are minutes, turns, cost.`);
356
+ for (const k of ['minutes', 'cost']) if (a.limits[k] !== undefined && !(typeof a.limits[k] === 'number' && Number.isFinite(a.limits[k]) && a.limits[k] > 0)) out.push(`agents.limits.${k} is a number above zero.`);
357
+ if (a.limits.turns !== undefined && !(Number.isInteger(a.limits.turns) && a.limits.turns > 0 && a.limits.turns <= MAX_NUMBER)) out.push('agents.limits.turns is a whole number above zero.');
358
+ }
359
+ }
360
+ return out;
361
+ }
362
+
282
363
  /** Whether a host is the machine itself: `localhost`, 127.x.x.x or ::1 */
283
364
  const isLoopbackHost = (host) => host === 'localhost' || host === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(host);
284
365
 
@@ -323,7 +404,7 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
323
404
  if (problems.length) throw new Error(`${path.relative(absRoot, configFile) || CONFIG_FILE}:\n ${problems.join('\n ')}`);
324
405
  }
325
406
  const config = {
326
- ...structuredClone({ ...DEFAULTS, sources: { ...DEFAULTS.sources }, personas: { ...DEFAULTS.personas }, cloud: { ...DEFAULTS.cloud }, agents: { ...DEFAULTS.agents }, releaseNotes: { ...DEFAULTS.releaseNotes } }),
407
+ ...structuredClone({ ...DEFAULTS, sources: { ...DEFAULTS.sources }, personas: { ...DEFAULTS.personas }, cloud: { ...DEFAULTS.cloud }, agents: { ...DEFAULTS.agents }, releaseNotes: { ...DEFAULTS.releaseNotes }, threads: { ...DEFAULTS.threads } }),
327
408
  ...raw,
328
409
  sources: { ...DEFAULTS.sources, ...(raw.sources || {}) },
329
410
  personas: { ...DEFAULTS.personas, ...(raw.personas || {}) },
@@ -332,6 +413,8 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
332
413
  deploys: { ...DEFAULTS.deploys, ...(raw.deploys || {}) },
333
414
  agents: { ...DEFAULTS.agents, ...(raw.agents || {}) },
334
415
  };
416
+ // `threads: false` stays false; an object is read over the default
417
+ config.threads = raw.threads === false ? false : { ...DEFAULTS.threads, ...(raw.threads || {}) };
335
418
  // `hookPoll: false` is `{ specs: false }`; an object is read over the default
336
419
  config.hookPoll = raw.hookPoll === false ? { specs: false } : { ...DEFAULTS.hookPoll, ...(raw.hookPoll || {}) };
337
420
  // `proposals` is the folder to everything downstream, whichever way the file wrote it; the options are read from `approveLands`
@@ -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
+ }
@@ -852,7 +852,7 @@ a.hx-forge-row:hover, button.hx-forge-row:hover { background: var(--hx-control-h
852
852
  .hx-forge-detail { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--hx-line); border-left: 4px solid var(--hx-indigo); border-radius: 10px; background: var(--hx-well-soft); }
853
853
  .hx-forge-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
854
854
  .hx-forge-detail-title { min-width: 0; font-size: 13px; font-weight: 700; color: var(--hx-ink); overflow-wrap: anywhere; }
855
- .hx-forge-sub, .hx-forge-subact, .hx-forge-confirm-title { margin: 4px 0 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--hx-slate-300); }
855
+ .hx-forge-sub, .hx-forge-subact, .hx-forge-runs-title, .hx-forge-confirm-title { margin: 4px 0 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--hx-slate-300); }
856
856
  .hx-forge-check { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; }
857
857
  .hx-forge-check .hx-link { display: inline-flex; align-items: center; min-height: 24px; }
858
858
  .hx-forge-lights { flex-basis: 100%; font-size: 11px; line-height: 1.5; }
@@ -913,3 +913,14 @@ 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
+ .hx-forge-runs { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 8px; }
917
+ .hx-forge-runs[hidden] { display: none; }
918
+
919
+ /* Threads (forge-panel.js, docs/threads.md): messages as written, read-only; a node's "Mentioned in" opens one */
920
+ .hx-thread-msgs { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
921
+ .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); }
922
+ .hx-thread-body { margin: 0; color: var(--hx-ink); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
923
+ .hx-thread-nodes { padding-left: 0; }
924
+ .hx-thread-compose { font-family: inherit; min-height: 8em; }
925
+ .hx-thread-open { display: block; max-width: 100%; text-align: left; overflow-wrap: anywhere; }
926
+ #forge-idea[hidden], #idea-form[hidden], #idea-message[hidden] { display: none; }