@davidvornholt/standards 0.10.2 → 0.11.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.
Files changed (43) hide show
  1. package/README.md +6 -1
  2. package/package.json +37 -1
  3. package/src/cli.ts +68 -11
  4. package/src/github-commands.ts +2 -0
  5. package/src/github-label-identity.ts +9 -0
  6. package/src/github-labels.ts +123 -0
  7. package/src/github-live-drift.ts +8 -0
  8. package/src/github-paginate.ts +37 -0
  9. package/src/github-settings-merge.ts +167 -0
  10. package/src/github-settings-parse.ts +82 -7
  11. package/src/github-settings.ts +5 -123
  12. package/src/poller-approval.ts +80 -0
  13. package/src/poller-claim.ts +154 -0
  14. package/src/poller-codex.ts +93 -0
  15. package/src/poller-commands.ts +78 -0
  16. package/src/poller-config.ts +188 -0
  17. package/src/poller-fix-outcome.ts +36 -0
  18. package/src/poller-fix-output.ts +135 -0
  19. package/src/poller-fix-publication.ts +187 -0
  20. package/src/poller-fix-run.ts +175 -0
  21. package/src/poller-fix-validation.ts +64 -0
  22. package/src/poller-github-publication.ts +23 -0
  23. package/src/poller-github-pulls.ts +189 -0
  24. package/src/poller-github-write.ts +87 -0
  25. package/src/poller-github.ts +190 -0
  26. package/src/poller-job-shared.ts +132 -0
  27. package/src/poller-outcome.ts +136 -0
  28. package/src/poller-output-integrity.ts +107 -0
  29. package/src/poller-prompts.ts +70 -0
  30. package/src/poller-protected-paths.ts +51 -0
  31. package/src/poller-protocol.ts +137 -0
  32. package/src/poller-review-artifacts.ts +119 -0
  33. package/src/poller-review-execution.ts +108 -0
  34. package/src/poller-review-output.ts +189 -0
  35. package/src/poller-review-publication.ts +153 -0
  36. package/src/poller-review-run.ts +160 -0
  37. package/src/poller-review-state.ts +126 -0
  38. package/src/poller-review-validation.ts +66 -0
  39. package/src/poller-schedule.ts +121 -0
  40. package/src/poller-tick.ts +144 -0
  41. package/src/poller-trust.ts +96 -0
  42. package/src/poller-units.ts +82 -0
  43. package/src/poller-workspace.ts +199 -0
@@ -4,21 +4,34 @@
4
4
  // github-diff.ts, and the API interaction in github-api.ts. Like cli.ts, this
5
5
  // module is zero-dependency so `bunx` can execute the published package.
6
6
 
7
+ import { labelIdentity } from './github-label-identity';
8
+
7
9
  // GitHub only enforces rulesets on private repositories on paid plans. The
8
10
  // seam can declare that fact so the gate skips rulesets loudly instead of
9
11
  // failing closed forever (personal accounts) or trusting rulesets the API
10
12
  // reports as active but a free-plan organization silently does not enforce.
11
13
  export type RulesetEnforcement = 'enforced' | 'unavailable-on-plan';
12
14
 
15
+ // Declared issue labels are an additive floor like rulesets: declared labels
16
+ // must exist with the declared color and description; extra live labels are
17
+ // not drift. The fix-poller protocol labels ride this seam to every consumer.
18
+ export type LabelDeclaration = {
19
+ readonly name: string;
20
+ readonly color: string;
21
+ readonly description: string;
22
+ };
23
+
13
24
  export type GithubSettings = {
14
25
  readonly repository: Readonly<Record<string, unknown>>;
15
26
  readonly rulesets: ReadonlyArray<Readonly<Record<string, unknown>>>;
27
+ readonly labels: ReadonlyArray<LabelDeclaration>;
16
28
  readonly rulesetEnforcement: RulesetEnforcement;
17
29
  };
18
30
 
19
31
  export type ParsedGithubSettings = {
20
32
  readonly repository: Readonly<Record<string, unknown>> | null;
21
33
  readonly rulesets: ReadonlyArray<unknown> | null;
34
+ readonly labels: ReadonlyArray<unknown> | null;
22
35
  readonly rulesetEnforcement: RulesetEnforcement | null;
23
36
  };
24
37
 
@@ -33,9 +46,21 @@ export const isNamedRuleset = (
33
46
  ): value is Readonly<Record<string, unknown>> & { readonly name: string } =>
34
47
  isRecord(value) && typeof value.name === 'string' && value.name.length > 0;
35
48
 
49
+ const LABEL_COLOR = /^[0-9a-f]{6}$/u;
50
+ const LABEL_DECLARATION_KEY_COUNT = 3;
51
+
52
+ export const isLabelDeclaration = (value: unknown): value is LabelDeclaration =>
53
+ isRecord(value) &&
54
+ Object.keys(value).length === LABEL_DECLARATION_KEY_COUNT &&
55
+ isNonEmptyString(value.name) &&
56
+ typeof value.color === 'string' &&
57
+ LABEL_COLOR.test(value.color) &&
58
+ isNonEmptyString(value.description);
59
+
36
60
  export const SETTINGS_KEYS: ReadonlySet<string> = new Set([
37
61
  'repository',
38
62
  'rulesets',
63
+ 'labels',
39
64
  ]);
40
65
  export const LOCAL_SETTINGS_KEYS: ReadonlySet<string> = new Set([
41
66
  ...SETTINGS_KEYS,
@@ -78,6 +103,51 @@ const rulesetListProblems = (
78
103
  return problems;
79
104
  };
80
105
 
106
+ const labelListProblems = (
107
+ labels: ReadonlyArray<unknown>,
108
+ label: string,
109
+ ): ReadonlyArray<string> => {
110
+ const problems: Array<string> = [];
111
+ const names = new Set<string>();
112
+ for (const [index, declaration] of labels.entries()) {
113
+ if (!isLabelDeclaration(declaration)) {
114
+ problems.push(
115
+ `${label} labels[${index}] must be {"name","color","description"} with a non-empty name, a 6-digit lowercase hex color, and a non-empty description`,
116
+ );
117
+ } else if (names.has(labelIdentity(declaration.name))) {
118
+ problems.push(
119
+ `${label} declares label "${declaration.name}" more than once`,
120
+ );
121
+ } else {
122
+ names.add(labelIdentity(declaration.name));
123
+ }
124
+ }
125
+ return problems;
126
+ };
127
+
128
+ type ListField = {
129
+ readonly label: string;
130
+ readonly field: string;
131
+ readonly listProblems: (
132
+ list: ReadonlyArray<unknown>,
133
+ label: string,
134
+ ) => ReadonlyArray<string>;
135
+ };
136
+
137
+ const parseDeclarationList = (
138
+ raw: unknown,
139
+ options: ListField,
140
+ problems: Array<string>,
141
+ ): ReadonlyArray<unknown> | null => {
142
+ const list = raw ?? [];
143
+ if (!Array.isArray(list)) {
144
+ problems.push(`${options.label} "${options.field}" must be an array`);
145
+ return null;
146
+ }
147
+ problems.push(...options.listProblems(list, options.label));
148
+ return list;
149
+ };
150
+
81
151
  export const parseSettings = (
82
152
  raw: unknown,
83
153
  label: string,
@@ -96,12 +166,16 @@ export const parseSettings = (
96
166
  if (!isRecord(repository)) {
97
167
  problems.push(`${label} "repository" must be an object`);
98
168
  }
99
- const rulesets = raw.rulesets ?? [];
100
- if (Array.isArray(rulesets)) {
101
- problems.push(...rulesetListProblems(rulesets, label));
102
- } else {
103
- problems.push(`${label} "rulesets" must be an array`);
104
- }
169
+ const rulesets = parseDeclarationList(
170
+ raw.rulesets,
171
+ { label, field: 'rulesets', listProblems: rulesetListProblems },
172
+ problems,
173
+ );
174
+ const labels = parseDeclarationList(
175
+ raw.labels,
176
+ { label, field: 'labels', listProblems: labelListProblems },
177
+ problems,
178
+ );
105
179
  // Only the opt-out value is accepted: enforcement is the default, and an
106
180
  // explicit "enforced" would be a second way to spell the same state.
107
181
  if (
@@ -115,7 +189,8 @@ export const parseSettings = (
115
189
  return {
116
190
  settings: {
117
191
  repository: isRecord(repository) ? repository : null,
118
- rulesets: Array.isArray(rulesets) ? rulesets : null,
192
+ rulesets,
193
+ labels,
119
194
  rulesetEnforcement: parseRulesetEnforcement(raw.rulesetEnforcement),
120
195
  },
121
196
  problems,
@@ -1,12 +1,12 @@
1
- // Merge canonical GitHub settings with the repo-owned extension. Parsing,
2
- // drift, and API work live in named modules; this stays dependency-free.
1
+ // Load and merge canonical GitHub settings with the repo-owned extension.
2
+ // Parsing lives in github-settings-parse.ts, merge policy in
3
+ // github-settings-merge.ts, drift in github-diff.ts, and API work in
4
+ // github-api.ts; this stays dependency-free.
3
5
 
6
+ import { mergeSettings } from './github-settings-merge';
4
7
  import {
5
- ENFORCEMENT_OPT_OUT,
6
8
  type GithubSettings,
7
- isNamedRuleset,
8
9
  LOCAL_SETTINGS_KEYS,
9
- type ParsedGithubSettings,
10
10
  parseSettings,
11
11
  SETTINGS_KEYS,
12
12
  } from './github-settings-parse';
@@ -16,124 +16,6 @@ export type LoadedGithubSettings = {
16
16
  readonly problems: ReadonlyArray<string>;
17
17
  };
18
18
 
19
- const completeSettings = (
20
- parsed: ParsedGithubSettings | null,
21
- ): GithubSettings | null => {
22
- if (
23
- parsed === null ||
24
- parsed.repository === null ||
25
- parsed.rulesets === null ||
26
- parsed.rulesetEnforcement === null
27
- ) {
28
- return null;
29
- }
30
- const rulesets = parsed.rulesets.filter(isNamedRuleset);
31
- if (rulesets.length !== parsed.rulesets.length) {
32
- return null;
33
- }
34
- return {
35
- repository: parsed.repository,
36
- rulesets,
37
- rulesetEnforcement: parsed.rulesetEnforcement,
38
- };
39
- };
40
-
41
- const enforcementMergeProblems = (
42
- local: ParsedGithubSettings | null,
43
- ): ReadonlyArray<string> => {
44
- if (
45
- local?.rulesetEnforcement !== ENFORCEMENT_OPT_OUT ||
46
- local.rulesets === null ||
47
- local.rulesets.length === 0
48
- ) {
49
- return [];
50
- }
51
- return [
52
- `.github/settings.local.json declares additional rulesets while "rulesetEnforcement" is "${ENFORCEMENT_OPT_OUT}"; remove the rulesets or the opt-out`,
53
- ];
54
- };
55
-
56
- const repositoryMergeProblems = (
57
- canonical: ParsedGithubSettings | null,
58
- local: ParsedGithubSettings | null,
59
- ): ReadonlyArray<string> => {
60
- if (
61
- canonical === null ||
62
- canonical.repository === null ||
63
- local === null ||
64
- local.repository === null
65
- ) {
66
- return [];
67
- }
68
- const canonicalRepository = canonical.repository;
69
- return Object.keys(local.repository)
70
- .filter((key) => key in canonicalRepository)
71
- .map(
72
- (key) =>
73
- `.github/settings.local.json repository."${key}" would override a canonical value; canonical settings are read-only`,
74
- );
75
- };
76
-
77
- const rulesetMergeProblems = (
78
- canonical: ParsedGithubSettings | null,
79
- local: ParsedGithubSettings | null,
80
- ): ReadonlyArray<string> => {
81
- if (
82
- canonical === null ||
83
- canonical.rulesets === null ||
84
- local === null ||
85
- local.rulesets === null
86
- ) {
87
- return [];
88
- }
89
- const canonicalNames = new Set(
90
- canonical.rulesets.filter(isNamedRuleset).map((ruleset) => ruleset.name),
91
- );
92
- return local.rulesets
93
- .filter(isNamedRuleset)
94
- .filter((ruleset) => canonicalNames.has(ruleset.name))
95
- .map(
96
- (ruleset) =>
97
- `.github/settings.local.json ruleset "${ruleset.name}" collides with a canonical ruleset; add a separately named ruleset to tighten further`,
98
- );
99
- };
100
-
101
- // The seam may only add. Overriding a canonical repository key or redefining a
102
- // canonical ruleset could weaken the canonical floor; GitHub layers multiple
103
- // rulesets strictest-wins, so adding a ruleset is always safe. The one
104
- // subtractive declaration is the ruleset-enforcement opt-out, which skips the
105
- // ruleset gate entirely rather than weakening any single rule.
106
- const mergeSettings = (
107
- canonical: ParsedGithubSettings | null,
108
- local: ParsedGithubSettings | null,
109
- ) => {
110
- const problems = [
111
- ...enforcementMergeProblems(local),
112
- ...repositoryMergeProblems(canonical, local),
113
- ...rulesetMergeProblems(canonical, local),
114
- ];
115
- const completeCanonical = completeSettings(canonical);
116
- const completeLocal = completeSettings(local);
117
- if (
118
- problems.length > 0 ||
119
- completeCanonical === null ||
120
- completeLocal === null
121
- ) {
122
- return { merged: null, problems };
123
- }
124
- return {
125
- merged: {
126
- repository: {
127
- ...completeCanonical.repository,
128
- ...completeLocal.repository,
129
- },
130
- rulesets: [...completeCanonical.rulesets, ...completeLocal.rulesets],
131
- rulesetEnforcement: completeLocal.rulesetEnforcement,
132
- },
133
- problems: [],
134
- };
135
- };
136
-
137
19
  type JsonParse = {
138
20
  readonly value: unknown;
139
21
  readonly problem: string | null;
@@ -0,0 +1,80 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { hasLabel } from './github-label-identity';
3
+ import {
4
+ collaboratorRole,
5
+ getIssue,
6
+ type IssueItem,
7
+ lastLabelEvent,
8
+ } from './poller-github';
9
+ import { isTrustedRole } from './poller-protocol';
10
+
11
+ export type ApprovalBinding = {
12
+ readonly id: string;
13
+ readonly repo: string;
14
+ readonly issueNumber: number;
15
+ readonly eventId: number;
16
+ readonly label: string;
17
+ readonly actorLogin: string;
18
+ readonly approvedAt: string;
19
+ readonly target: string;
20
+ };
21
+
22
+ type ApprovalContext = {
23
+ readonly token: string | null;
24
+ readonly repo: string;
25
+ readonly issueNumber: number;
26
+ };
27
+
28
+ const stableDigest = (value: unknown): string =>
29
+ createHash('sha256').update(JSON.stringify(value)).digest('hex');
30
+
31
+ export const issueRevision = (issue: IssueItem): string =>
32
+ `issue:${stableDigest({ title: issue.title, body: issue.body })}`;
33
+
34
+ export const prRevision = (
35
+ baseRef: string,
36
+ baseSha: string,
37
+ headSha: string,
38
+ ): string => `pr:${stableDigest({ baseRef, baseSha, headSha })}`;
39
+
40
+ export const readApprovalBinding = async (
41
+ context: ApprovalContext,
42
+ label: string,
43
+ target: string,
44
+ ): Promise<ApprovalBinding | string> => {
45
+ const issue = await getIssue(
46
+ context.token,
47
+ context.repo,
48
+ context.issueNumber,
49
+ );
50
+ if (!hasLabel(issue.labels, label)) {
51
+ return `"${label}" is not currently present on ${context.repo}#${context.issueNumber}`;
52
+ }
53
+ const event = await lastLabelEvent(
54
+ context.token,
55
+ context.repo,
56
+ context.issueNumber,
57
+ label,
58
+ );
59
+ if (event === null) {
60
+ return `no "${label}" label event found on ${context.repo}#${context.issueNumber}`;
61
+ }
62
+ const role = await collaboratorRole(
63
+ context.token,
64
+ context.repo,
65
+ event.actorLogin,
66
+ );
67
+ if (!isTrustedRole(role)) {
68
+ return `"${label}" on ${context.repo}#${context.issueNumber} was applied by ${event.actorLogin} (role: ${role}); only admin or maintain roles may approve automation`;
69
+ }
70
+ const fields = {
71
+ repo: context.repo,
72
+ issueNumber: context.issueNumber,
73
+ eventId: event.id,
74
+ label,
75
+ actorLogin: event.actorLogin,
76
+ approvedAt: event.createdAt,
77
+ target,
78
+ };
79
+ return { id: stableDigest(fields), ...fields };
80
+ };
@@ -0,0 +1,154 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { hasLabel } from './github-label-identity';
3
+ import { type ApprovalBinding, readApprovalBinding } from './poller-approval';
4
+ import {
5
+ collaboratorRole,
6
+ getIssue,
7
+ type IssueComment,
8
+ lastLabelEvent,
9
+ listIssueComments,
10
+ } from './poller-github';
11
+ import { createComment } from './poller-github-write';
12
+ import { CLAIM_MARKER, isTrustedRole } from './poller-protocol';
13
+
14
+ export type ClaimBinding = {
15
+ readonly approval: ApprovalBinding;
16
+ readonly claimLabel: string;
17
+ readonly claimEpoch: string;
18
+ readonly markerId: number;
19
+ };
20
+
21
+ type ClaimMarker = Omit<ClaimBinding, 'markerId'> & {
22
+ readonly nonce: string;
23
+ };
24
+
25
+ type ClaimContext = {
26
+ readonly token: string | null;
27
+ readonly repo: string;
28
+ readonly issueNumber: number;
29
+ };
30
+
31
+ const markerBody = (marker: ClaimMarker): string =>
32
+ `${CLAIM_MARKER}\n${JSON.stringify(marker)}`;
33
+
34
+ const parseMarker = (comment: IssueComment): ClaimMarker | null => {
35
+ if (!comment.body.startsWith(`${CLAIM_MARKER}\n`)) {
36
+ return null;
37
+ }
38
+ try {
39
+ const raw = JSON.parse(comment.body.slice(CLAIM_MARKER.length + 1)) as {
40
+ readonly approval?: ApprovalBinding;
41
+ readonly claimLabel?: unknown;
42
+ readonly claimEpoch?: unknown;
43
+ readonly nonce?: unknown;
44
+ };
45
+ if (
46
+ typeof raw.claimLabel !== 'string' ||
47
+ typeof raw.claimEpoch !== 'string' ||
48
+ typeof raw.nonce !== 'string' ||
49
+ raw.approval === undefined ||
50
+ typeof raw.approval.id !== 'string'
51
+ ) {
52
+ return null;
53
+ }
54
+ return {
55
+ approval: raw.approval,
56
+ claimLabel: raw.claimLabel,
57
+ claimEpoch: raw.claimEpoch,
58
+ nonce: raw.nonce,
59
+ };
60
+ } catch {
61
+ return null;
62
+ }
63
+ };
64
+
65
+ const winningMarkerId = async (
66
+ context: ClaimContext,
67
+ binding: Omit<ClaimBinding, 'markerId'>,
68
+ ): Promise<number | null> => {
69
+ const comments = await listIssueComments(
70
+ context.token,
71
+ context.repo,
72
+ context.issueNumber,
73
+ );
74
+ let winner: number | null = null;
75
+ for (const comment of comments) {
76
+ const marker = parseMarker(comment);
77
+ const differs =
78
+ marker?.approval.id !== binding.approval.id ||
79
+ marker.claimLabel !== binding.claimLabel ||
80
+ marker.claimEpoch !== binding.claimEpoch;
81
+ if (!differs) {
82
+ // biome-ignore lint/performance/noAwaitInLoops: claim authors must be checked against their current role; the usually-one-item list is deliberately fail-closed.
83
+ const role = await collaboratorRole(
84
+ context.token,
85
+ context.repo,
86
+ comment.authorLogin,
87
+ );
88
+ if (isTrustedRole(role) && (winner === null || comment.id < winner)) {
89
+ winner = comment.id;
90
+ }
91
+ }
92
+ }
93
+ return winner;
94
+ };
95
+
96
+ export const acquireClaim = async (
97
+ context: ClaimContext,
98
+ approval: ApprovalBinding,
99
+ claimLabel: string,
100
+ ): Promise<ClaimBinding | null> => {
101
+ const event = await lastLabelEvent(
102
+ context.token,
103
+ context.repo,
104
+ context.issueNumber,
105
+ claimLabel,
106
+ );
107
+ if (event === null) {
108
+ throw new Error(`no "${claimLabel}" claim event found`);
109
+ }
110
+ const provisional = {
111
+ approval,
112
+ claimLabel,
113
+ claimEpoch: String(event.id),
114
+ };
115
+ const nonce = randomUUID();
116
+ const markerId = await createComment(
117
+ context.token,
118
+ context.repo,
119
+ context.issueNumber,
120
+ markerBody({ ...provisional, nonce }),
121
+ );
122
+ const winner = await winningMarkerId(context, provisional);
123
+ return winner === markerId ? { ...provisional, markerId } : null;
124
+ };
125
+
126
+ export const validateClaim = async (
127
+ context: ClaimContext,
128
+ claim: ClaimBinding,
129
+ currentTarget: string,
130
+ ): Promise<string | null> => {
131
+ const current = await readApprovalBinding(
132
+ context,
133
+ claim.approval.label,
134
+ currentTarget,
135
+ );
136
+ if (typeof current === 'string') {
137
+ return current;
138
+ }
139
+ if (current.id !== claim.approval.id) {
140
+ return 'approval no longer matches the exact approved revision/head';
141
+ }
142
+ const issue = await getIssue(
143
+ context.token,
144
+ context.repo,
145
+ context.issueNumber,
146
+ );
147
+ if (!hasLabel(issue.labels, claim.claimLabel)) {
148
+ return `"${claim.claimLabel}" is no longer present`;
149
+ }
150
+ const winner = await winningMarkerId(context, claim);
151
+ return winner === claim.markerId
152
+ ? null
153
+ : 'claim ownership changed or could not be proven';
154
+ };
@@ -0,0 +1,93 @@
1
+ // Codex invocation for poller jobs. The agent runs headless inside the job
2
+ // worktree and hands results back through the outcome file — never stdout,
3
+ // which is unreliable once agent tools are active. The poller then verifies
4
+ // effects (commits, diffs, gates) instead of trusting the narration.
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import { rmSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import process from 'node:process';
10
+ import { isRecord } from './github-settings-parse';
11
+ import type { PollerConfig } from './poller-config';
12
+ import { OUTCOME_DIR } from './poller-protocol';
13
+
14
+ const MS_PER_SECOND = 1000;
15
+ const SECONDS_PER_MINUTE = 60;
16
+ const MS_PER_MINUTE = SECONDS_PER_MINUTE * MS_PER_SECOND;
17
+ const STDERR_SNIPPET_LIMIT = 2000;
18
+
19
+ export type CodexRunResult = {
20
+ readonly succeeded: boolean;
21
+ readonly failure: string | null;
22
+ };
23
+
24
+ type CodexExecutor = (
25
+ file: string,
26
+ args: ReadonlyArray<string>,
27
+ options: {
28
+ readonly encoding: 'utf8';
29
+ readonly timeout: number;
30
+ readonly stdio: readonly ['ignore', 'ignore', 'pipe'];
31
+ readonly env: Record<string, string | undefined>;
32
+ },
33
+ ) => unknown;
34
+
35
+ const defaultExecutor: CodexExecutor = execFileSync;
36
+
37
+ // Remove the poller's direct GitHub token variables so an approved Codex run
38
+ // cannot trivially bypass the protected-path and trust checks with API writes.
39
+ // The shared service identity is not credential-isolated: auth state and other
40
+ // ambient credentials in HOME remain readable as an explicitly accepted risk.
41
+ const agentEnv = (): Record<string, string | undefined> => {
42
+ const env = { ...process.env };
43
+ env.GH_TOKEN = undefined;
44
+ env.GITHUB_TOKEN = undefined;
45
+ env.STANDARDS_POLLER_GIT_TOKEN = undefined;
46
+ return env;
47
+ };
48
+
49
+ export const runCodex = (
50
+ workDir: string,
51
+ prompt: string,
52
+ config: PollerConfig,
53
+ execute: CodexExecutor = defaultExecutor,
54
+ ): CodexRunResult => {
55
+ rmSync(join(workDir, OUTCOME_DIR), { recursive: true, force: true });
56
+ try {
57
+ execute(
58
+ 'codex',
59
+ [
60
+ 'exec',
61
+ '--cd',
62
+ workDir,
63
+ '--sandbox',
64
+ 'workspace-write',
65
+ '-c',
66
+ 'sandbox_workspace_write.network_access=true',
67
+ '-m',
68
+ config.model,
69
+ '-c',
70
+ `model_reasoning_effort=${JSON.stringify(config.reasoningEffort)}`,
71
+ ...config.extraCodexArgs,
72
+ prompt,
73
+ ],
74
+ {
75
+ encoding: 'utf8',
76
+ timeout: config.runTimeoutMinutes * MS_PER_MINUTE,
77
+ stdio: ['ignore', 'ignore', 'pipe'],
78
+ env: agentEnv(),
79
+ },
80
+ );
81
+ return { succeeded: true, failure: null };
82
+ } catch (error) {
83
+ const stderr =
84
+ isRecord(error) && typeof error.stderr === 'string'
85
+ ? error.stderr.slice(-STDERR_SNIPPET_LIMIT)
86
+ : '';
87
+ const message = error instanceof Error ? error.message : String(error);
88
+ return {
89
+ succeeded: false,
90
+ failure: `codex exec failed: ${message}\n${stderr}`,
91
+ };
92
+ }
93
+ };
@@ -0,0 +1,78 @@
1
+ // CLI wiring for the poller command family. A tick is a gate-style command:
2
+ // it reports what it did and fails loudly when any repository could not be
3
+ // processed, so a red systemd unit is the durable signal that jobs stalled.
4
+
5
+ import { existsSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { dirname, resolve } from 'node:path';
8
+ import { resolveToken } from './github-api';
9
+ import { type PollerConfig, parsePollerConfig } from './poller-config';
10
+ import { runPollerTick } from './poller-tick';
11
+ import { runPollerPrintUnits } from './poller-units';
12
+
13
+ export type PollerCommandOptions = {
14
+ readonly configPath: string | undefined;
15
+ readonly printUnits: boolean;
16
+ };
17
+
18
+ const loadConfig = async (configPath: string): Promise<PollerConfig> => {
19
+ const path = resolve(configPath);
20
+ if (!existsSync(path)) {
21
+ throw new Error(`poller config not found: ${path}`);
22
+ }
23
+ let parsed: unknown;
24
+ try {
25
+ parsed = JSON.parse(await readFile(path, 'utf8'));
26
+ } catch (error) {
27
+ throw new Error(`poller config must contain valid JSON: ${path}`, {
28
+ cause: error,
29
+ });
30
+ }
31
+ const { config, problems } = parsePollerConfig(parsed, dirname(path));
32
+ if (config === null) {
33
+ throw new Error(
34
+ [
35
+ `invalid poller config ${path}:`,
36
+ ...problems.map((p) => ` - ${p}`),
37
+ ].join('\n'),
38
+ );
39
+ }
40
+ return config;
41
+ };
42
+
43
+ export const runPollerCommand = async (
44
+ options: PollerCommandOptions,
45
+ ): Promise<boolean> => {
46
+ if (options.configPath === undefined) {
47
+ console.error('standards poller: --config <path> is required');
48
+ return false;
49
+ }
50
+ // Every branch loads the config first: the units derive their tick budget
51
+ // from it, and a broken file must fail now, not on the first timer firing
52
+ // at 3am.
53
+ const config = await loadConfig(options.configPath);
54
+ if (options.printUnits) {
55
+ runPollerPrintUnits(options.configPath, config);
56
+ return true;
57
+ }
58
+ const token = resolveToken();
59
+ if (token === null) {
60
+ console.error(
61
+ 'standards poller: no GitHub token (GH_TOKEN, GITHUB_TOKEN, or gh auth); the poller cannot label, push, or open PRs anonymously',
62
+ );
63
+ return false;
64
+ }
65
+ const { lines, problems } = await runPollerTick(config, token, Date.now());
66
+ for (const line of lines) {
67
+ console.log(` ${line}`);
68
+ }
69
+ if (problems.length > 0) {
70
+ console.error(`standards poller: ${problems.length} problem(s):`);
71
+ console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
72
+ return false;
73
+ }
74
+ console.log(
75
+ `standards poller: tick complete (${lines.length} event(s) across ${config.repos.length} repo(s))`,
76
+ );
77
+ return true;
78
+ };