@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.
- package/README.md +13 -11
- package/package.json +1 -1
- package/runtime/skills/distribution/generated/recipes.json +17 -17
- package/runtime/skills/distribution/scripts/bundles.mjs +113 -25
- package/runtime/skills/engineering/engineering-runtime/scripts/invocation-policy.mjs +187 -0
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +19 -0
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +9 -3
- package/skills/cicd-pipeline/mergify/references/watch-contract.md +7 -0
- package/skills/cicd-pipeline/mergify/scripts/watch-pr-delivery-core.mjs +60 -2
- package/skills/engineering/engineering-runtime/coherence/workflow.json +23 -16
- package/skills/engineering/engineering-runtime/scripts/invocation-policy.mjs +187 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +19 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +9 -3
- package/skills/engineering/resolve-issues/generated/workflow-repair-policy.json +20 -13
- package/skills/engineering/resolve-issues/scripts/run-state.mjs +14 -6
|
@@ -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.
|
|
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: {
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
416
|
+
"version": "0.14.0"
|
|
416
417
|
},
|
|
417
|
-
"contentDigest": "
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
468
|
+
"version": "0.14.0"
|
|
468
469
|
},
|
|
469
|
-
"contentDigest": "
|
|
470
|
+
"contentDigest": "0d71ce70f3180bba021005e056f028ef5a2018eb187078ab8c691a5a000a924e",
|
|
470
471
|
"digestExcludes": [
|
|
471
472
|
"coherence/workflow.json"
|
|
472
473
|
]
|
|
473
474
|
}
|
|
474
475
|
],
|
|
475
|
-
"releaseIdentity": "
|
|
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.
|
|
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.
|
|
550
|
+
"command": "npx @1aboveio/skills@0.14.0 install --group engineering-workflow --yes"
|
|
544
551
|
}
|
|
545
552
|
],
|
|
546
553
|
"onFailure": {
|