@1aboveio/skills 0.13.0 → 0.14.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,187 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ import { isMainModule } from './main-module.mjs';
8
+
9
+ export const MANUAL_ONLY = 'manual-only';
10
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
11
+
12
+ function frontmatterBounds(contents) {
13
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(contents);
14
+ if (!match) throw new Error('SKILL.md must begin with YAML frontmatter');
15
+ return { body: match[1], end: match[0].length };
16
+ }
17
+
18
+ function manualOnlySkill(contents) {
19
+ const frontmatter = frontmatterBounds(contents);
20
+ const declarations = frontmatter.body.match(/^disable-model-invocation\s*:\s*.*$/gm) ?? [];
21
+ if (declarations.length > 1) throw new Error('SKILL.md repeats disable-model-invocation');
22
+
23
+ const body = declarations.length === 1
24
+ ? frontmatter.body.replace(/^disable-model-invocation\s*:\s*.*$/m, 'disable-model-invocation: true')
25
+ : `${frontmatter.body}\ndisable-model-invocation: true`;
26
+ return `---\n${body}\n---\n${contents.slice(frontmatter.end)}`;
27
+ }
28
+
29
+ function manualOnlyOpenAi(contents) {
30
+ const policyHeaders = contents.match(/^policy\s*:\s*$/gm) ?? [];
31
+ if (policyHeaders.length > 1) throw new Error('agents/openai.yaml repeats policy');
32
+ if (policyHeaders.length === 0) {
33
+ const prefix = contents.length === 0 || contents.endsWith('\n') ? contents : `${contents}\n`;
34
+ return `${prefix}policy:\n allow_implicit_invocation: false\n`;
35
+ }
36
+
37
+ const lines = contents.split('\n');
38
+ const policyIndex = lines.findIndex((line) => /^policy\s*:\s*$/.test(line));
39
+ let policyEnd = lines.length;
40
+ for (let index = policyIndex + 1; index < lines.length; index += 1) {
41
+ if (/^[^\s#][^:]*\s*:/.test(lines[index])) {
42
+ policyEnd = index;
43
+ break;
44
+ }
45
+ }
46
+ const declarations = lines
47
+ .slice(policyIndex + 1, policyEnd)
48
+ .filter((line) => /^\s+allow_implicit_invocation\s*:/.test(line));
49
+ if (declarations.length > 1) throw new Error('agents/openai.yaml repeats policy.allow_implicit_invocation');
50
+ if (declarations.length === 1) {
51
+ return lines.map((line, index) => (
52
+ index > policyIndex && index < policyEnd && /^\s+allow_implicit_invocation\s*:/.test(line)
53
+ ? ' allow_implicit_invocation: false'
54
+ : line
55
+ )).join('\n');
56
+ }
57
+ lines.splice(policyIndex + 1, 0, ' allow_implicit_invocation: false');
58
+ return lines.join('\n');
59
+ }
60
+
61
+ export function manualOnlyPolicyFindings(skillDirectory) {
62
+ const findings = [];
63
+ const skillPath = join(skillDirectory, 'SKILL.md');
64
+ const openaiPath = join(skillDirectory, 'agents', 'openai.yaml');
65
+ if (!existsSync(skillPath)) return ['SKILL.md is missing'];
66
+ const skill = readFileSync(skillPath, 'utf8');
67
+ const declarations = frontmatterBounds(skill).body.match(/^disable-model-invocation\s*:\s*true\s*$/gm) ?? [];
68
+ if (declarations.length !== 1) findings.push('SKILL.md must declare disable-model-invocation: true exactly once');
69
+ if (!existsSync(openaiPath)) {
70
+ findings.push('agents/openai.yaml is missing');
71
+ } else {
72
+ const openai = readFileSync(openaiPath, 'utf8');
73
+ const policies = openai.match(/^policy\s*:\s*$/gm) ?? [];
74
+ const implicit = openai.match(/^\s+allow_implicit_invocation\s*:\s*false\s*$/gm) ?? [];
75
+ if (policies.length !== 1 || implicit.length !== 1) {
76
+ findings.push('agents/openai.yaml must declare policy.allow_implicit_invocation: false exactly once');
77
+ }
78
+ }
79
+ return findings;
80
+ }
81
+
82
+ // Restore the reviewed upstream bytes for source-integrity hashing. The installed tree must first
83
+ // pass manualOnlyPolicyFindings; these exact trailing additions are the only intentional divergence.
84
+ export function reviewedSourceInjections(skillDirectory, policy) {
85
+ if (policy !== MANUAL_ONLY) return [];
86
+ const findings = manualOnlyPolicyFindings(skillDirectory);
87
+ if (findings.length > 0) throw new Error(findings.join('; '));
88
+ const skill = readFileSync(join(skillDirectory, 'SKILL.md'), 'utf8')
89
+ .replace(/^disable-model-invocation: true\n/m, '');
90
+ const openai = readFileSync(join(skillDirectory, 'agents', 'openai.yaml'), 'utf8')
91
+ .replace(/policy:\n allow_implicit_invocation: false\n?$/, '');
92
+ return [
93
+ { relativePath: 'SKILL.md', contents: skill },
94
+ { relativePath: 'agents/openai.yaml', contents: openai },
95
+ ];
96
+ }
97
+
98
+ export function clearInvocationPolicy({ installName, policy, skillsRoot = join(homedir(), '.agents', 'skills') }) {
99
+ if (!SKILL_NAME.test(installName ?? '')) throw new Error(`invalid installed skill name ${JSON.stringify(installName)}`);
100
+ if (policy !== MANUAL_ONLY) throw new Error(`unsupported invocation policy ${JSON.stringify(policy)}`);
101
+ const skillDirectory = join(skillsRoot, installName);
102
+ const skillPath = join(skillDirectory, 'SKILL.md');
103
+ const openaiPath = join(skillDirectory, 'agents', 'openai.yaml');
104
+ if (!existsSync(skillPath) || !existsSync(openaiPath)) throw new Error(`installed skill is incomplete: ${skillDirectory}`);
105
+ const skill = readFileSync(skillPath, 'utf8');
106
+ const openai = readFileSync(openaiPath, 'utf8');
107
+ const hasSkillPolicy = /^disable-model-invocation\s*:/m.test(frontmatterBounds(skill).body);
108
+ const hasOpenAiPolicy = /^policy\s*:\s*$/m.test(openai) || /^\s+allow_implicit_invocation\s*:/m.test(openai);
109
+ if (!hasSkillPolicy && !hasOpenAiPolicy) {
110
+ return { installName, policy: 'implicit', skillDirectory, changed: false };
111
+ }
112
+ const injected = Object.fromEntries(reviewedSourceInjections(skillDirectory, policy)
113
+ .map((entry) => [entry.relativePath, entry.contents]));
114
+ writeFileSync(skillPath, injected['SKILL.md']);
115
+ writeFileSync(openaiPath, injected['agents/openai.yaml']);
116
+ return { installName, policy: 'implicit', skillDirectory, changed: true };
117
+ }
118
+
119
+ export function applyInvocationPolicy({ installName, policy, skillsRoot = join(homedir(), '.agents', 'skills') }) {
120
+ if (!SKILL_NAME.test(installName ?? '')) throw new Error(`invalid installed skill name ${JSON.stringify(installName)}`);
121
+ if (policy !== MANUAL_ONLY) throw new Error(`unsupported invocation policy ${JSON.stringify(policy)}`);
122
+ const skillDirectory = join(skillsRoot, installName);
123
+ const skillPath = join(skillDirectory, 'SKILL.md');
124
+ if (!existsSync(skillPath)) throw new Error(`installed skill is missing: ${skillPath}`);
125
+ const openaiPath = join(skillDirectory, 'agents', 'openai.yaml');
126
+
127
+ const skillBefore = readFileSync(skillPath, 'utf8');
128
+ const skillAfter = manualOnlySkill(skillBefore);
129
+ if (skillAfter !== skillBefore) writeFileSync(skillPath, skillAfter);
130
+
131
+ mkdirSync(dirname(openaiPath), { recursive: true });
132
+ const openaiBefore = existsSync(openaiPath) ? readFileSync(openaiPath, 'utf8') : '';
133
+ const openaiAfter = manualOnlyOpenAi(openaiBefore);
134
+ if (openaiAfter !== openaiBefore) writeFileSync(openaiPath, openaiAfter);
135
+
136
+ const findings = manualOnlyPolicyFindings(skillDirectory);
137
+ if (findings.length > 0) throw new Error(findings.join('; '));
138
+ return { installName, policy, skillDirectory, changed: skillAfter !== skillBefore || openaiAfter !== openaiBefore };
139
+ }
140
+
141
+ function selectionEnablesTdd(env = process.env) {
142
+ const stateHome = env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state');
143
+ const path = join(stateHome, '1aboveio-skills', 'selection.json');
144
+ if (!existsSync(path)) return true;
145
+ try {
146
+ const state = JSON.parse(readFileSync(path, 'utf8'));
147
+ if (state.tddImplicitInvocation === undefined) return true;
148
+ if (typeof state.tddImplicitInvocation !== 'boolean') {
149
+ throw new Error('tddImplicitInvocation must be a boolean');
150
+ }
151
+ return state.tddImplicitInvocation;
152
+ } catch (error) {
153
+ throw new Error(`cannot read TDD invocation selection at ${path}: ${error.message}`);
154
+ }
155
+ }
156
+
157
+ export function reconcileInvocationPolicy(options) {
158
+ return selectionEnablesTdd(options.env)
159
+ ? clearInvocationPolicy(options)
160
+ : applyInvocationPolicy(options);
161
+ }
162
+
163
+ function parseArgs(argv) {
164
+ const parsed = { installName: null, policy: null, skillsRoot: undefined, fromSelection: false };
165
+ for (let index = 0; index < argv.length; index += 1) {
166
+ const arg = argv[index];
167
+ if (arg === '--skill') parsed.installName = argv[++index];
168
+ else if (arg === '--policy') parsed.policy = argv[++index];
169
+ else if (arg === '--skills-root') parsed.skillsRoot = argv[++index];
170
+ else if (arg === '--from-selection') parsed.fromSelection = true;
171
+ else throw new Error(`unknown argument ${arg}`);
172
+ }
173
+ return parsed;
174
+ }
175
+
176
+ if (isMainModule(import.meta.url)) {
177
+ try {
178
+ const parsed = parseArgs(process.argv.slice(2));
179
+ const result = parsed.fromSelection
180
+ ? reconcileInvocationPolicy(parsed)
181
+ : applyInvocationPolicy(parsed);
182
+ process.stdout.write(`${JSON.stringify(result)}\n`);
183
+ } catch (error) {
184
+ process.stderr.write(`invocation-policy: ${error.message}\n`);
185
+ process.exitCode = 1;
186
+ }
187
+ }
@@ -3,12 +3,14 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import { isAbsolute, join, normalize, relative } from 'node:path';
4
4
  import { isDeepStrictEqual } from 'node:util';
5
5
 
6
+ import { reviewedSourceInjections } from './invocation-policy.mjs';
6
7
  import { isMainModule } from './main-module.mjs';
7
8
  import {
8
9
  HARNESS_RUNTIME_DEPENDENCY_MEMBERS,
9
10
  MATT_DEPENDENCY_MEMBERS,
10
11
  WORKFLOW_OWNED_MEMBERS,
11
12
  WORKFLOW_TRUSTED_DEPENDENCY_CLOSURES,
13
+ WORKFLOW_TRUSTED_INVOCATION_POLICIES,
12
14
  WORKFLOW_TRUSTED_REPAIR_RECIPE,
13
15
  WORKFLOW_TRUSTED_SOURCES,
14
16
  } from './workflow-policy.mjs';
@@ -18,6 +20,7 @@ export {
18
20
  MATT_DEPENDENCY_MEMBERS,
19
21
  WORKFLOW_OWNED_MEMBERS,
20
22
  WORKFLOW_TRUSTED_DEPENDENCY_CLOSURES,
23
+ WORKFLOW_TRUSTED_INVOCATION_POLICIES,
21
24
  WORKFLOW_TRUSTED_REPAIR_RECIPE,
22
25
  } from './workflow-policy.mjs';
23
26
 
@@ -67,6 +70,9 @@ export const WORKFLOW_TRUSTED_POLICY = deepFreeze({
67
70
  ownership: 'dependency',
68
71
  sourcePath: MATT_MEMBER_PATHS[installName],
69
72
  expectedSource: MATT_SOURCE,
73
+ ...(WORKFLOW_TRUSTED_INVOCATION_POLICIES.find((entry) => entry.installName === installName)
74
+ ? { invocationPolicy: WORKFLOW_TRUSTED_INVOCATION_POLICIES.find((entry) => entry.installName === installName).policy }
75
+ : {}),
70
76
  })),
71
77
  ...HARNESS_RUNTIME_DEPENDENCY_MEMBERS.map((installName) => ({
72
78
  installName,
@@ -331,6 +337,9 @@ function validateManifest(manifest) {
331
337
  || JSON.stringify(member.digestExcludes) !== JSON.stringify(expectedExcludes)) {
332
338
  add('member-digest-contract-invalid', memberName, 'The member digest exclusion contract is missing or malformed.');
333
339
  }
340
+ if (member.invocationPolicy !== expectedMember.invocationPolicy) {
341
+ add('member-invocation-policy-invalid', memberName, 'The runtime invocation policy is missing or malformed.');
342
+ }
334
343
  }
335
344
  }
336
345
 
@@ -465,8 +474,18 @@ export function verifyWorkflowCoherence(options = {}) {
465
474
  // for a member to claim a scope its source does not have — and `validateManifest` has already
466
475
  // proved `expectedSource` deep-equal to the trusted policy above (`member-source-identity-
467
476
  // invalid` returns before this loop), so the derivation reads trusted data, not manifest claims.
477
+ let policyInjections = [];
478
+ if (layout === 'installed' && member.invocationPolicy) {
479
+ try {
480
+ policyInjections = reviewedSourceInjections(directory, member.invocationPolicy);
481
+ } catch {
482
+ // An unchanged upstream tree is the explicit "implicit enabled" option. Partial or
483
+ // malformed overlays still fail because their raw digest matches neither allowed state.
484
+ }
485
+ }
468
486
  actualDigest = contentDigest(directory, {
469
487
  exclude: member.digestExcludes,
488
+ injected: policyInjections,
470
489
  published: firstPartySource(member.expectedSource),
471
490
  });
472
491
  } catch (error) {
@@ -47,6 +47,10 @@ export const MATT_DEPENDENCY_MEMBERS = deepFreeze([
47
47
  // runtime imports. Order follows the coherence member census so the same tree always
48
48
  // produces the same identity. A generated file remains part of its owner's content
49
49
  // digest and is also named explicitly so a scoped verdict reports every identity used.
50
+ export const WORKFLOW_TRUSTED_INVOCATION_POLICIES = deepFreeze([
51
+ { installName: 'tdd', policy: 'manual-only' },
52
+ ]);
53
+
50
54
  export const WORKFLOW_TRUSTED_DEPENDENCY_CLOSURES = deepFreeze([
51
55
  {
52
56
  invokedSkill: 'resolve-issues',
@@ -93,7 +97,7 @@ export const WORKFLOW_TRUSTED_SOURCES = deepFreeze({
93
97
  id: 'first-party',
94
98
  type: 'first-party',
95
99
  package: '@1aboveio/skills',
96
- version: '0.13.0',
100
+ version: '0.14.0',
97
101
  },
98
102
  matt: {
99
103
  id: 'matt-pocock',
@@ -106,6 +110,7 @@ export const WORKFLOW_TRUSTED_SOURCES = deepFreeze({
106
110
 
107
111
  const nativeSkillsPrefix = 'npx skills@1.5.22 add';
108
112
  const nativeSkillsTarget = '--global --agent universal claude-code --skill';
113
+ const invocationPolicyCommand = 'node "$HOME/.agents/skills/engineering-runtime/scripts/invocation-policy.mjs" --skill tdd --policy manual-only --from-selection';
109
114
  const externalArchiveLimits = 'SKILLS_DOWNLOAD_MAX_BYTES=33554432 SKILLS_EXTRACT_MAX_FILES=4096 SKILLS_EXTRACT_MAX_BYTES=67108864';
110
115
  const mattMembersArgument = MATT_DEPENDENCY_MEMBERS.join(' ');
111
116
  const firstPartyMembers = deepFreeze([
@@ -135,9 +140,10 @@ export const WORKFLOW_TRUSTED_REPAIR_RECIPE = deepFreeze({
135
140
  revision: WORKFLOW_TRUSTED_SOURCES.matt.revision,
136
141
  installPath: null,
137
142
  members: MATT_DEPENDENCY_MEMBERS,
143
+ invocationPolicies: WORKFLOW_TRUSTED_INVOCATION_POLICIES,
138
144
  commands: [{
139
145
  transport: 'https',
140
- command: `${externalArchiveLimits} ${nativeSkillsPrefix} https://codeload.github.com/mattpocock/skills/tar.gz/${WORKFLOW_TRUSTED_SOURCES.matt.revision} ${nativeSkillsTarget} ${mattMembersArgument}`,
146
+ command: `${externalArchiveLimits} ${nativeSkillsPrefix} https://codeload.github.com/mattpocock/skills/tar.gz/${WORKFLOW_TRUSTED_SOURCES.matt.revision} ${nativeSkillsTarget} ${mattMembersArgument} --yes && ${invocationPolicyCommand}`,
141
147
  }],
142
148
  onFailure: {
143
149
  action: 'stop',
@@ -154,7 +160,7 @@ export const WORKFLOW_TRUSTED_REPAIR_RECIPE = deepFreeze({
154
160
  commands: [
155
161
  {
156
162
  transport: 'npm',
157
- command: `npx ${publicPackageRelease} install --group engineering-workflow`,
163
+ command: `npx ${publicPackageRelease} install --group engineering-workflow --yes`,
158
164
  },
159
165
  ],
160
166
  onFailure: {
@@ -125,6 +125,13 @@ A watcher must retain history for the PR it is watching.
125
125
  - `WAITING`: membership was observed, then one readable absence — re-read suspicion, not terminal.
126
126
  - `STALLED`: no observable queue/admission state has changed for the configured stall bound. Terminal hand-back, not a merge failure. The bound is **phase-aware**: pre-admission (never observed membership this process) uses the shorter `--admission-stall-minutes` (default 3) and reports `reason: "admission-stall"`; after membership has been observed, the full `--stall-minutes` (default 10) applies and reports `reason: "stall-bound"`. Same terminal outcome and exit code either way — only wall time and `reason` change. A dead admission path (forgotten enqueue, stale Mergify status, unreadable queue API) must not burn the post-entry bound. An `admission-stall` while Mergify is visibly waiting on a green-or-pending admission check is often a **too-short window**, not a broken enqueue — relaunch with the CI-gated 15m pair above rather than treating 60m as the next step.
127
127
 
128
+ Before queue membership, a completed failing non-Mergify GitHub check settles
129
+ immediately as `STALLED` with `reason: "admission-checks-failed"` and a
130
+ `failedChecks` summary. This is not `DEQUEUED`: the PR was never admitted.
131
+ Mergify's own queue/protection checks are excluded, pending/neutral/skipped
132
+ checks keep watching, and after membership Mergify remains the lifecycle
133
+ authority for failed batches and dequeue routing.
134
+
128
135
  The critical invariant: after a watcher has **observed** queue entry in this process (`everActive`), it must never keep reporting `queued:false` as a routine healthy snapshot. If the PR did not merge, loss of queue membership is a queue exit. Seeding `seenQueued` via `--seen-queued` before the first successful membership poll must not invent that observation.
129
136
 
130
137
  ## Re-read Rule
@@ -104,6 +104,19 @@ export function classifyObservation({
104
104
  if (mayCountLoss) {
105
105
  return { terminal: false, outcome: 'WAITING', reason: 'queue-membership-missing-reread', active, seenQueued: true, everActive: ever }
106
106
  }
107
+ if (Array.isArray(pr.failedChecks) && pr.failedChecks.length > 0) {
108
+ return {
109
+ terminal: true,
110
+ outcome: 'STALLED',
111
+ reason: 'admission-checks-failed',
112
+ detail: 'PR admission failed before active Mergify queue membership was observed.',
113
+ failedChecks: pr.failedChecks,
114
+ active,
115
+ seenQueued: seenQueued === true,
116
+ everActive: false,
117
+ preAdmission: true,
118
+ }
119
+ }
107
120
  // Seeded seenQueued with explicit everActive=false: still pre-admission for THIS process.
108
121
  // Keep seenQueued so phases/callers retain the resume hint, but do not start the
109
122
  // lost-membership counter until membership is observed.
@@ -136,10 +149,16 @@ export function nextPhases(phases = [], outcome) {
136
149
 
137
150
  export function observationKey({ pr = {}, queue = {} } = {}) {
138
151
  const labels = Array.isArray(pr.labels) ? [...pr.labels].sort().join(',') : ''
152
+ const checks = [...(pr.failedChecks || []), ...(pr.pendingChecks || [])]
153
+ .map((check) => `${check.name}:${check.status}`)
154
+ .sort()
155
+ .join(',')
139
156
  return JSON.stringify([
140
157
  pr.state ?? null,
141
158
  pr.mergedAt ?? null,
159
+ pr.headRefOid ?? null,
142
160
  labels,
161
+ checks,
143
162
  queue.queued ?? null,
144
163
  queue.queueState ?? queue.state ?? null,
145
164
  queue.position ?? null,
@@ -283,6 +302,28 @@ function parseJson(text) {
283
302
  try { return JSON.parse(text) } catch { return null }
284
303
  }
285
304
 
305
+ const MERGIFY_CHECKS = new Set(['Mergify Merge Protections', 'Mergify Merge Queue'])
306
+ const FAILED_CHECK_STATES = new Set(['ACTION_REQUIRED', 'CANCELLED', 'ERROR', 'FAILURE', 'STARTUP_FAILURE', 'TIMED_OUT'])
307
+ const PENDING_CHECK_STATES = new Set(['EXPECTED', 'IN_PROGRESS', 'PENDING', 'QUEUED', 'REQUESTED', 'WAITING'])
308
+
309
+ export function summarizeStatusChecks(rollup) {
310
+ const failedChecks = []
311
+ const pendingChecks = []
312
+ for (const check of Array.isArray(rollup) ? rollup : []) {
313
+ const name = check?.name || check?.context || 'unnamed-check'
314
+ if (MERGIFY_CHECKS.has(name)) continue
315
+ const status = String(check?.conclusion || check?.state || check?.status || 'UNKNOWN').toUpperCase()
316
+ const summary = {
317
+ name,
318
+ status,
319
+ detailsUrl: check?.detailsUrl || check?.targetUrl || null,
320
+ }
321
+ if (FAILED_CHECK_STATES.has(status)) failedChecks.push(summary)
322
+ else if (PENDING_CHECK_STATES.has(status)) pendingChecks.push(summary)
323
+ }
324
+ return { failedChecks, pendingChecks }
325
+ }
326
+
286
327
  // Issue #629: the one piece of history a single `labels` snapshot cannot carry — WHEN a
287
328
  // `dequeued` label was applied. `gh pr view --json` has no `timelineItems` field in the CLI
288
329
  // version this was built against (`gh pr view --json timelineItems` errors "Unknown JSON
@@ -380,18 +421,21 @@ function prObservation({ repo, pr, intervalSeconds, stallMinutes, admissionStall
380
421
  // `comments` costs nothing extra to request in this same call and carries the one signal
381
422
  // `dequeuedAtFromComments` needs — no additional subprocess read, so the READS_PER_POLL
382
423
  // budget below is unaffected by issue #629's fix.
383
- const result = run('gh', ['pr', 'view', String(pr), '--repo', repo, '--json', 'state,mergedAt,mergeCommit,labels,url,comments'], { timeout: readTimeoutForArgs({ intervalSeconds, stallMinutes, admissionStallMinutes, maxMinutes, everActive }) })
424
+ const result = run('gh', ['pr', 'view', String(pr), '--repo', repo, '--json', 'state,mergedAt,mergeCommit,headRefOid,labels,url,comments,statusCheckRollup'], { timeout: readTimeoutForArgs({ intervalSeconds, stallMinutes, admissionStallMinutes, maxMinutes, everActive }) })
384
425
  if (!result.ok) return { readable: false, error: result.stderr || result.stdout }
385
426
  const data = parseJson(result.stdout)
386
427
  if (!data) return { readable: false, error: 'gh pr view returned invalid JSON' }
428
+ const checks = summarizeStatusChecks(data.statusCheckRollup)
387
429
  return {
388
430
  readable: true,
389
431
  state: data.state || null,
390
432
  mergedAt: data.mergedAt || null,
391
433
  mergeCommitOid: data.mergeCommit?.oid || null,
434
+ headRefOid: data.headRefOid || null,
392
435
  labels: (data.labels || []).map((label) => label.name).filter(Boolean),
393
436
  url: data.url || null,
394
437
  dequeuedAtMs: dequeuedAtFromComments(data.comments),
438
+ ...checks,
395
439
  }
396
440
  }
397
441
 
@@ -501,6 +545,8 @@ The script observes only; it never queues, dequeues, merges, or records state.
501
545
 
502
546
  Enqueue first. On a repo with manual delivery (no auto_merge_conditions, autoqueue: false)
503
547
  an unqueued PR never enters the queue on its own, so this watch can only ever end STALLED.
548
+ Before admission, a completed failing non-Mergify GitHub check ends the watch as STALLED
549
+ with reason admission-checks-failed; after admission, Mergify owns failure/dequeue routing.
504
550
 
505
551
  Options:
506
552
  --interval-seconds <n> Poll interval, default 90
@@ -595,6 +641,7 @@ export async function runCli(argv) {
595
641
  })
596
642
  if (verdict.everActive === true) everActive = true
597
643
  if (verdict.seenQueued === true) seenQueued = true
644
+ if (verdict.preAdmission === true) phases = nextPhases(phases, 'NOT_YET_QUEUED')
598
645
  phases = nextPhases(phases, verdict.outcome)
599
646
  const payload = {
600
647
  outcome: verdict.outcome,
@@ -605,10 +652,21 @@ export async function runCli(argv) {
605
652
  seenQueued,
606
653
  everActive,
607
654
  phases,
655
+ failedChecks: verdict.failedChecks || pr.failedChecks || [],
608
656
  missingActiveReads,
609
657
  observations,
610
658
  elapsedMs: Date.now() - startedMs,
611
- pr: { state: pr.state || null, mergedAt: pr.mergedAt || null, labels: pr.labels || [], url: pr.url || null, readable: pr.readable !== false, dequeuedAtMs: pr.dequeuedAtMs ?? null },
659
+ pr: {
660
+ state: pr.state || null,
661
+ mergedAt: pr.mergedAt || null,
662
+ headRefOid: pr.headRefOid || null,
663
+ labels: pr.labels || [],
664
+ url: pr.url || null,
665
+ readable: pr.readable !== false,
666
+ dequeuedAtMs: pr.dequeuedAtMs ?? null,
667
+ failedChecks: pr.failedChecks || [],
668
+ pendingChecks: pr.pendingChecks || [],
669
+ },
612
670
  queue: { queued: queue.queued === true, queueState: queue.queueState || null, position: queue.position ?? null, readable: queue.readable !== false },
613
671
  }
614
672
 
@@ -294,7 +294,8 @@
294
294
  "reviewedTree": "04b0fcb78e3de7c58744fcba2528354cc64ab988"
295
295
  },
296
296
  "contentDigest": "81eca2a5b53a63f481c0849be7a663a8cd43d5cf53f32b644ec0a2f50cf91aa2",
297
- "digestExcludes": []
297
+ "digestExcludes": [],
298
+ "invocationPolicy": "manual-only"
298
299
  },
299
300
  {
300
301
  "installName": "to-spec",
@@ -360,7 +361,7 @@
360
361
  "id": "first-party",
361
362
  "type": "first-party",
362
363
  "package": "@1aboveio/skills",
363
- "version": "0.13.0"
364
+ "version": "0.14.0"
364
365
  },
365
366
  "contentDigest": "eff6c7b5931bce5b2265a619bccddd371a89df6ce7bc2edc74f11d00060a71dd",
366
367
  "digestExcludes": []
@@ -373,7 +374,7 @@
373
374
  "id": "first-party",
374
375
  "type": "first-party",
375
376
  "package": "@1aboveio/skills",
376
- "version": "0.13.0"
377
+ "version": "0.14.0"
377
378
  },
378
379
  "contentDigest": "08230dc57a53d6526692b50138038abbd80abce1a067e01c52ad6acc182caf7e",
379
380
  "digestExcludes": []
@@ -386,7 +387,7 @@
386
387
  "id": "first-party",
387
388
  "type": "first-party",
388
389
  "package": "@1aboveio/skills",
389
- "version": "0.13.0"
390
+ "version": "0.14.0"
390
391
  },
391
392
  "contentDigest": "9eea7bfba348ddaea1b934c9a7fabdd9b101df8a146e1c93e947da4d335f1d98",
392
393
  "digestExcludes": []
@@ -399,7 +400,7 @@
399
400
  "id": "first-party",
400
401
  "type": "first-party",
401
402
  "package": "@1aboveio/skills",
402
- "version": "0.13.0"
403
+ "version": "0.14.0"
403
404
  },
404
405
  "contentDigest": "58b0556228a9271cf9e33727a05fc08a4fdfd29512ec0936b169f0a55d60e6ef",
405
406
  "digestExcludes": []
@@ -412,9 +413,9 @@
412
413
  "id": "first-party",
413
414
  "type": "first-party",
414
415
  "package": "@1aboveio/skills",
415
- "version": "0.13.0"
416
+ "version": "0.14.0"
416
417
  },
417
- "contentDigest": "b2ab1d8d031a4fe7077ccd6a21eaea223eaded592eaf3fa8d4b38b842f639416",
418
+ "contentDigest": "9eac84bee618e57564eb702d198484b6e78f8d27e3db621d087f0dd2dc048b58",
418
419
  "digestExcludes": []
419
420
  },
420
421
  {
@@ -425,7 +426,7 @@
425
426
  "id": "first-party",
426
427
  "type": "first-party",
427
428
  "package": "@1aboveio/skills",
428
- "version": "0.13.0"
429
+ "version": "0.14.0"
429
430
  },
430
431
  "contentDigest": "b5af4dccf703362d4f41cac4fdff48305f652a00338d85975a2e5c35ec6bc61c",
431
432
  "digestExcludes": []
@@ -438,7 +439,7 @@
438
439
  "id": "first-party",
439
440
  "type": "first-party",
440
441
  "package": "@1aboveio/skills",
441
- "version": "0.13.0"
442
+ "version": "0.14.0"
442
443
  },
443
444
  "contentDigest": "f048fd00c69f2dc666fc7a3096cfeee6dfba73933bfb69602f3f0e26159798cc",
444
445
  "digestExcludes": []
@@ -451,7 +452,7 @@
451
452
  "id": "first-party",
452
453
  "type": "first-party",
453
454
  "package": "@1aboveio/skills",
454
- "version": "0.13.0"
455
+ "version": "0.14.0"
455
456
  },
456
457
  "contentDigest": "90a7c4e1ad6da1e632ea2c5259e4967ffebb84353adccbca8dfc5d6bc50b60c1",
457
458
  "digestExcludes": []
@@ -464,15 +465,15 @@
464
465
  "id": "first-party",
465
466
  "type": "first-party",
466
467
  "package": "@1aboveio/skills",
467
- "version": "0.13.0"
468
+ "version": "0.14.0"
468
469
  },
469
- "contentDigest": "fde380e2e8812d5926aa10b424c0fc8ce03c4b5aeec6f359dae2c4f509ff5f67",
470
+ "contentDigest": "0d71ce70f3180bba021005e056f028ef5a2018eb187078ab8c691a5a000a924e",
470
471
  "digestExcludes": [
471
472
  "coherence/workflow.json"
472
473
  ]
473
474
  }
474
475
  ],
475
- "releaseIdentity": "20ba85bc4da566d3574aca6990057c19bc670b0825b69bfd6cbea3065fb53d47",
476
+ "releaseIdentity": "5c171520b6818f86f6b88cd60e5e4e0168dd6f89256690925c02758105c6c8a5",
476
477
  "lifecycleAuthority": "native-skills-cli",
477
478
  "repairRecipe": {
478
479
  "id": "engineering-workflow-dependency-first",
@@ -509,10 +510,16 @@
509
510
  "triage",
510
511
  "wayfinder"
511
512
  ],
513
+ "invocationPolicies": [
514
+ {
515
+ "installName": "tdd",
516
+ "policy": "manual-only"
517
+ }
518
+ ],
512
519
  "commands": [
513
520
  {
514
521
  "transport": "https",
515
- "command": "SKILLS_DOWNLOAD_MAX_BYTES=33554432 SKILLS_EXTRACT_MAX_FILES=4096 SKILLS_EXTRACT_MAX_BYTES=67108864 npx skills@1.5.22 add https://codeload.github.com/mattpocock/skills/tar.gz/ed37663cc5fbef691ddfecd080dff42f7e7e350d --global --agent universal claude-code --skill batch-grill-me code-review codebase-design diagnosing-bugs domain-modeling grill-me grill-with-docs grilling handoff improve-codebase-architecture prototype research resolving-merge-conflicts setup-matt-pocock-skills tdd to-spec to-tickets triage wayfinder"
522
+ "command": "SKILLS_DOWNLOAD_MAX_BYTES=33554432 SKILLS_EXTRACT_MAX_FILES=4096 SKILLS_EXTRACT_MAX_BYTES=67108864 npx skills@1.5.22 add https://codeload.github.com/mattpocock/skills/tar.gz/ed37663cc5fbef691ddfecd080dff42f7e7e350d --global --agent universal claude-code --skill batch-grill-me code-review codebase-design diagnosing-bugs domain-modeling grill-me grill-with-docs grilling handoff improve-codebase-architecture prototype research resolving-merge-conflicts setup-matt-pocock-skills tdd to-spec to-tickets triage wayfinder --yes && node \"$HOME/.agents/skills/engineering-runtime/scripts/invocation-policy.mjs\" --skill tdd --policy manual-only --from-selection"
516
523
  }
517
524
  ],
518
525
  "onFailure": {
@@ -524,7 +531,7 @@
524
531
  "sourceId": "first-party",
525
532
  "sourceType": "first-party",
526
533
  "package": "@1aboveio/skills",
527
- "version": "0.13.0",
534
+ "version": "0.14.0",
528
535
  "installPath": null,
529
536
  "members": [
530
537
  "harness-runtime",
@@ -540,7 +547,7 @@
540
547
  "commands": [
541
548
  {
542
549
  "transport": "npm",
543
- "command": "npx @1aboveio/skills@0.13.0 install --group engineering-workflow"
550
+ "command": "npx @1aboveio/skills@0.14.0 install --group engineering-workflow --yes"
544
551
  }
545
552
  ],
546
553
  "onFailure": {