@windyroad/itil 3.4.0-preview.1366 → 3.4.0-preview.1368
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/package.json +1 -1
- package/skills/outcome-led-planning/references/github-coordination.md +6 -0
- package/skills/outcome-led-planning/references/github-project-checkpoint.md +73 -0
- package/skills/outcome-led-planning/scripts/github-project-checkpoint.mjs +142 -0
- package/skills-codex/outcome-led-planning/references/github-coordination.md +6 -0
- package/skills-codex/outcome-led-planning/references/github-project-checkpoint.md +73 -0
- package/skills-codex/outcome-led-planning/scripts/github-project-checkpoint.mjs +142 -0
package/package.json
CHANGED
|
@@ -188,6 +188,12 @@ actual acknowledgement.
|
|
|
188
188
|
|
|
189
189
|
At each work checkpoint, reconcile tracking in this order:
|
|
190
190
|
|
|
191
|
+
For a join/resume checkpoint with a selected Project and working `gh`, MUST use
|
|
192
|
+
the [executable Project checkpoint](github-project-checkpoint.md). Read its
|
|
193
|
+
inspection and handle current discussion before applying it; do not manually
|
|
194
|
+
publish synchronization-success text on that route. Other checkpoints and
|
|
195
|
+
authorized alternatives retain the rules below.
|
|
196
|
+
|
|
191
197
|
1. **Establish scope.** Reuse the established backend. Establish Project selection
|
|
192
198
|
separately from the backend, using configuration, project requirements or
|
|
193
199
|
explicit direction: selected/required, none, or unresolved. Do not infer none
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Execute a selected-Project resume checkpoint
|
|
2
|
+
|
|
3
|
+
For a join/resume checkpoint with a selected GitHub Project and working `gh`,
|
|
4
|
+
planning and `contribute` MUST use the bundled
|
|
5
|
+
`scripts/github-project-checkpoint.mjs` operation. Do not manually publish a
|
|
6
|
+
Project synchronization-success claim on this route. The operation reads the
|
|
7
|
+
records/discussion, updates the two items, verifies their saved fields and only
|
|
8
|
+
then generates the task's synchronization result. It does not allocate owners,
|
|
9
|
+
approve a plan, acknowledge another person or complete the increment.
|
|
10
|
+
|
|
11
|
+
Resolve the script relative to this installed planning skill, including when
|
|
12
|
+
called from `contribute`. Use the existing authenticated `gh` account and saved
|
|
13
|
+
project configuration; no new grants, account changes or tokens are required by
|
|
14
|
+
the operation. Local and GitHub Issues-only tracking keep their existing paths.
|
|
15
|
+
|
|
16
|
+
## Inspect, decide, apply
|
|
17
|
+
|
|
18
|
+
Create a request from the actual records and Project fields. IDs below are
|
|
19
|
+
placeholders. Preserve each record's own verified agent/session; do not copy the
|
|
20
|
+
task contributor onto the increment. The status option must be the existing
|
|
21
|
+
In progress equivalent, never Done.
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"outcome": 1,
|
|
26
|
+
"increment": {"issue": 2, "itemId": "INCREMENT_ITEM_ID", "agent": "verified increment owner/session"},
|
|
27
|
+
"task": {"issue": 4, "itemId": "TASK_ITEM_ID", "agent": "verified task owner/session"},
|
|
28
|
+
"statusFieldId": "STATUS_FIELD_ID",
|
|
29
|
+
"agentFieldId": "AGENT_FIELD_ID",
|
|
30
|
+
"statusOptionId": "IN_PROGRESS_OPTION_ID",
|
|
31
|
+
"nextAction": "the actual next step in this task"
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
1. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs inspect request.json`.
|
|
36
|
+
The default configuration is `.agents/planning.json` in the project. An
|
|
37
|
+
existing project-declared configuration path may be supplied as the final
|
|
38
|
+
argument. Save the returned JSON in a permitted working location.
|
|
39
|
+
2. Read that inspection, including the increment discussion, current revision,
|
|
40
|
+
dependencies and claims. Acknowledge relevant handoffs through the authorized
|
|
41
|
+
channel and resolve overlap or material changes under the existing rules.
|
|
42
|
+
The operation reading a comment does not mean its recipient acknowledged it.
|
|
43
|
+
If that work changes these records, inspect again before applying.
|
|
44
|
+
3. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs apply request.json inspection.json`.
|
|
45
|
+
Supply the same explicit configuration path last when one was used above.
|
|
46
|
+
The operation rechecks inspected context, updates only task/increment
|
|
47
|
+
Status/Agent, reads back the actual fields, then publishes generated task
|
|
48
|
+
progress and reads it back. It preserves Horizon and the rest of the brief.
|
|
49
|
+
The current brief needs a literal `Next action:` line; if absent, maintain
|
|
50
|
+
that field truthfully before inspecting again. Do not erase existing content.
|
|
51
|
+
|
|
52
|
+
Use direct commands with literal paths supported by the runtime permissions.
|
|
53
|
+
Do not infer unavailable authority from one rejected compound command when an
|
|
54
|
+
already-authorized direct invocation remains available.
|
|
55
|
+
|
|
56
|
+
## Read the result
|
|
57
|
+
|
|
58
|
+
- `synchronized`: the requested fields and generated task update were read back.
|
|
59
|
+
This establishes this checkpoint only, not task or increment completion.
|
|
60
|
+
- `changed-context`: inspect the returned facts and resolve the change. A body
|
|
61
|
+
changed before publication stays untouched, even if fields already changed.
|
|
62
|
+
- `pending`: keep the precise affected operation and next action visible. A
|
|
63
|
+
partial or ambiguous field result cannot generate synchronization success.
|
|
64
|
+
The operation reads saved state before resolving uncertain writes and never
|
|
65
|
+
automatically repeats a mutation. If the pending note itself could not be
|
|
66
|
+
saved, its output does not prove the issue was updated.
|
|
67
|
+
|
|
68
|
+
Do not treat an exit code, assignment or local note as a saved-field result.
|
|
69
|
+
Keep useful authorized issue work moving while Project work is pending. If
|
|
70
|
+
`gh` or this operation is unavailable, use other already-authorized capabilities
|
|
71
|
+
under the coordination protocol, preserving the same truthful claim dependency.
|
|
72
|
+
Do not request broader access while an existing authorized route works, or
|
|
73
|
+
claim the executable operation enforced an untested browser/manual path.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const itemQuery = `query($ids:[ID!]!,$agentFieldId:ID!){nodes(ids:$ids){... on ProjectV2Item{
|
|
7
|
+
id project{id url} content{... on Issue{number url parent{number url}}}
|
|
8
|
+
fieldValues(first:100){nodes{... on ProjectV2ItemFieldSingleSelectValue{
|
|
9
|
+
optionId name field{... on ProjectV2SingleSelectField{id}}
|
|
10
|
+
} ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id}}}}
|
|
11
|
+
pageInfo{hasNextPage}}
|
|
12
|
+
}} agentField:node(id:$agentFieldId){... on ProjectV2Field{dataType}}}`;
|
|
13
|
+
const start = '<!-- outcome-led-planning:project-checkpoint:start -->';
|
|
14
|
+
const end = '<!-- outcome-led-planning:project-checkpoint:end -->';
|
|
15
|
+
const load = path => JSON.parse(readFileSync(path, 'utf8'));
|
|
16
|
+
const requireValue = (ok, message) => { if (!ok) throw new Error(message); };
|
|
17
|
+
|
|
18
|
+
function call(args, input) {
|
|
19
|
+
const result = spawnSync('gh', args, { input, encoding: 'utf8', timeout: 60000 });
|
|
20
|
+
return { ...result, failure: result.error?.message || (result.status !== 0 ? result.stderr.trim() || `gh exited ${result.status}` : '') };
|
|
21
|
+
}
|
|
22
|
+
function json(args, input) {
|
|
23
|
+
const result = call(args, input);
|
|
24
|
+
if (result.failure) throw new Error(result.failure);
|
|
25
|
+
const value = JSON.parse(result.stdout);
|
|
26
|
+
if (value.errors?.length) throw new Error(value.errors.map(error => error.message).join('; '));
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
function scope(request, config) {
|
|
30
|
+
requireValue(config.version === 1 && config.backend === 'github', 'This operation requires the saved GitHub backend.');
|
|
31
|
+
const repository = config.github?.repository;
|
|
32
|
+
requireValue(typeof repository === 'string' && /^[\w.-]+\/[\w.-]+$/.test(repository), 'Invalid configured GitHub repository.');
|
|
33
|
+
const url = new URL(config.github?.projectUrl);
|
|
34
|
+
const match = url.pathname.match(/^\/(?:users|orgs)\/([\w.-]+)\/projects\/([1-9]\d*)(?:\/views\/[1-9]\d*)?\/?$/);
|
|
35
|
+
requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && match && !url.username && !url.password, 'A selected GitHub Project URL is required.');
|
|
36
|
+
requireValue([request.outcome, request.increment?.issue, request.task?.issue].every(n => Number.isSafeInteger(n) && n > 0), 'Outcome, increment and task issue numbers are required.');
|
|
37
|
+
requireValue(new Set([request.outcome, request.increment.issue, request.task.issue]).size === 3, 'The three issue roles must be distinct.');
|
|
38
|
+
for (const value of [request.increment.itemId, request.task.itemId, request.statusFieldId, request.agentFieldId, request.statusOptionId]) {
|
|
39
|
+
requireValue(typeof value === 'string' && value.length > 0 && !/[\r\n\0]/.test(value), 'Existing item, field and option IDs are required.');
|
|
40
|
+
}
|
|
41
|
+
requireValue(request.increment.itemId !== request.task.itemId && request.statusFieldId !== request.agentFieldId, 'Item and field roles must be distinct.');
|
|
42
|
+
return { repository, owner: match[1], number: match[2], projectUrl: `https://github.com${url.pathname.split('/views/')[0].replace(/\/$/, '')}` };
|
|
43
|
+
}
|
|
44
|
+
function issue(number, bound) {
|
|
45
|
+
return json(['issue', 'view', String(number), '--repo', bound.repository, '--json', 'number,url,body,state,assignees,labels,comments']);
|
|
46
|
+
}
|
|
47
|
+
function items(request, bound, projectId) {
|
|
48
|
+
const response = json(['api', 'graphql', '--input', '-'], JSON.stringify({ query: itemQuery, variables: { ids: [request.increment.itemId, request.task.itemId], agentFieldId: request.agentFieldId } }));
|
|
49
|
+
requireValue(response.data.agentField?.dataType === 'TEXT', 'The Agent field must be a text field.');
|
|
50
|
+
return response.data.nodes.map((item, index) => {
|
|
51
|
+
const record = index === 0 ? request.increment : request.task;
|
|
52
|
+
const parent = index === 0 ? request.outcome : request.increment.issue;
|
|
53
|
+
const expected = `https://github.com/${bound.repository}/issues/${record.issue}`;
|
|
54
|
+
requireValue(item && item.id === record.itemId && item.project.id === projectId && item.project.url === bound.projectUrl, 'Project item does not belong to the selected Project.');
|
|
55
|
+
requireValue(item.content?.url.toLowerCase() === expected.toLowerCase() && item.content.parent?.number === parent && item.content.parent.url.toLowerCase() === `https://github.com/${bound.repository}/issues/${parent}`.toLowerCase(), 'Current native issue parent or repository does not match the requested hierarchy.');
|
|
56
|
+
requireValue(!item.fieldValues.pageInfo.hasNextPage, 'Project field response is incomplete; inspect the remaining fields before applying.');
|
|
57
|
+
return { id: item.id, issue: record.issue, fields: Object.fromEntries(item.fieldValues.nodes.filter(value => value.field?.id).map(value => [value.field.id, 'text' in value ? { text: value.text } : { optionId: value.optionId, name: value.name }]).sort(([a], [b]) => a.localeCompare(b))) };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function inspect(request, config) {
|
|
61
|
+
const bound = scope(request, config);
|
|
62
|
+
const actor = json(['api', 'user']).login;
|
|
63
|
+
const project = json(['project', 'view', bound.number, '--owner', bound.owner, '--format', 'json']);
|
|
64
|
+
requireValue(project.url === bound.projectUrl && !project.closed, 'Selected Project is unavailable or closed.');
|
|
65
|
+
const allFields = json(['project', 'field-list', bound.number, '--owner', bound.owner, '--format', 'json']).fields;
|
|
66
|
+
const status = allFields.find(field => field.id === request.statusFieldId);
|
|
67
|
+
const agent = allFields.find(field => field.id === request.agentFieldId);
|
|
68
|
+
const option = status?.options?.find(value => value.id === request.statusOptionId);
|
|
69
|
+
requireValue(status?.name === 'Status' && status.type === 'ProjectV2SingleSelectField' && agent?.name === 'Agent' && agent.type === 'ProjectV2Field' && option, 'Requested fields must be the selected Project Status and Agent fields.');
|
|
70
|
+
requireValue(option.name.replace(/[-_\s]/g, '').toLowerCase() === 'inprogress', 'A resume checkpoint requires the existing In progress status option.');
|
|
71
|
+
const records = { outcome: issue(request.outcome, bound), increment: issue(request.increment.issue, bound), task: issue(request.task.issue, bound) };
|
|
72
|
+
requireValue(Object.values(records).every(record => record.state === 'OPEN'), 'This resume operation requires open outcome, increment and task records.');
|
|
73
|
+
return { context: { actor, repository: bound.repository, project: { id: project.id, url: project.url }, fields: { status: { id: status.id, option }, agent: { id: agent.id } }, issues: records, items: items(request, bound, project.id) } };
|
|
74
|
+
}
|
|
75
|
+
function bodyFor(body, request, status, observed, reason) {
|
|
76
|
+
// shortcut: only the observed literal Next action line; extend when an adopter needs another brief format.
|
|
77
|
+
requireValue(/^Next action:[^\r\n]*/m.test(body), 'Task brief needs its current literal Next action line; no body changes were made.');
|
|
78
|
+
const first = body.indexOf(start);
|
|
79
|
+
const last = body.indexOf(end);
|
|
80
|
+
requireValue((first === -1 && last === -1) || (first >= 0 && last > first && body.indexOf(start, first + 1) === -1 && body.indexOf(end, last + 1) === -1), 'Existing checkpoint block is malformed; preserve the body and repair that block first.');
|
|
81
|
+
let preserved = first < 0 ? body : body.slice(0, first) + body.slice(last + end.length);
|
|
82
|
+
const next = status === 'synchronized' ? request.nextAction : `Resolve the pending Project checkpoint: ${reason}`;
|
|
83
|
+
preserved = preserved.replace(/^Next action:[^\r\n]*/m, () => `Next action: ${next}`);
|
|
84
|
+
const rows = status === 'synchronized' ? observed.map(item => `- Issue #${item.issue}: Status ${JSON.stringify(item.fields[request.statusFieldId].name)}; Agent ${JSON.stringify(item.fields[request.agentFieldId].text)}.`).join('\n') : `Pending: ${reason}`;
|
|
85
|
+
return `${preserved.trimEnd()}\n\n${start}\n## Current Project checkpoint\n\nResult: ${status}\n${rows}\n${end}\n`;
|
|
86
|
+
}
|
|
87
|
+
function apply(request, inspection, config) {
|
|
88
|
+
for (const value of [request.increment?.agent, request.task?.agent, request.nextAction]) requireValue(typeof value === 'string' && value.trim() && !/[\r\n\0]/.test(value), 'Verified agent/session values and one next action are required.');
|
|
89
|
+
const current = inspect(request, config);
|
|
90
|
+
if (!isDeepStrictEqual(current.context, inspection.context)) return { status: 'changed-context', record: request.task.issue, nextAction: 'Read the returned current context and resolve its changes before applying.', ...current };
|
|
91
|
+
const bound = scope(request, config);
|
|
92
|
+
const projectId = current.context.project.id;
|
|
93
|
+
const originalBody = current.context.issues.task.body;
|
|
94
|
+
bodyFor(originalBody, request, 'pending', [], 'validation');
|
|
95
|
+
let observed = current.context.items;
|
|
96
|
+
let failure = '';
|
|
97
|
+
let operation = '';
|
|
98
|
+
try {
|
|
99
|
+
for (const record of [request.increment, request.task]) {
|
|
100
|
+
for (const [field, flag, value] of [[request.agentFieldId, '--text', record.agent], [request.statusFieldId, '--single-select-option-id', request.statusOptionId]]) {
|
|
101
|
+
operation = `Issue #${record.issue}, item ${record.itemId}, ${field === request.agentFieldId ? 'Agent' : 'Status'} field ${field}`;
|
|
102
|
+
const result = call(['project', 'item-edit', '--id', record.itemId, '--field-id', field, '--project-id', projectId, flag, value, '--format', 'json']);
|
|
103
|
+
if (result.failure) {
|
|
104
|
+
observed = items(request, bound, projectId);
|
|
105
|
+
const saved = observed.find(item => item.id === record.itemId).fields[field];
|
|
106
|
+
if ((flag === '--text' ? saved?.text : saved?.optionId) !== value) throw new Error(result.failure);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
operation = `Saved Status/Agent readback for issues #${request.increment.issue} and #${request.task.issue}`;
|
|
111
|
+
observed = items(request, bound, projectId);
|
|
112
|
+
for (const record of [request.increment, request.task]) {
|
|
113
|
+
const saved = observed.find(item => item.id === record.itemId).fields;
|
|
114
|
+
requireValue(saved[request.agentFieldId]?.text === record.agent && saved[request.statusFieldId]?.optionId === request.statusOptionId, `Saved Project fields for issue #${record.issue} do not match the requested state.`);
|
|
115
|
+
}
|
|
116
|
+
} catch (error) { failure = `${operation}: ${error.message}`; }
|
|
117
|
+
const freshBody = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
|
|
118
|
+
if (freshBody !== originalBody) return { status: 'changed-context', record: request.task.issue, lastObservedFields: observed, nextAction: 'Preserve the changed task body; inspect it before publishing any checkpoint result.' };
|
|
119
|
+
const status = failure ? 'pending' : 'synchronized';
|
|
120
|
+
const body = bodyFor(freshBody, request, status, observed, failure);
|
|
121
|
+
const written = call(['issue', 'edit', String(request.task.issue), '--repo', bound.repository, '--body-file', '-'], body);
|
|
122
|
+
const saved = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
|
|
123
|
+
if (saved !== body) return { status: 'pending', record: request.task.issue, reason: written.failure || 'Saved issue body does not match the checkpoint update.', lastObservedFields: observed, nextAction: 'Read the saved issue body before deciding on another write.' };
|
|
124
|
+
return { status, record: request.task.issue, lastObservedFields: observed, ...(failure ? { reason: failure, nextAction: 'Resolve the pending operation before claiming synchronization.' } : {}) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (process.argv[2] === '--help') {
|
|
128
|
+
console.log('Inspect: node github-project-checkpoint.mjs inspect REQUEST.json [CONFIG.json]\nApply: node github-project-checkpoint.mjs apply REQUEST.json INSPECTION.json [CONFIG.json]');
|
|
129
|
+
} else {
|
|
130
|
+
try {
|
|
131
|
+
const [mode, requestPath, third, fourth] = process.argv.slice(2);
|
|
132
|
+
requireValue(mode === 'inspect' || mode === 'apply', 'Expected inspect or apply; use --help for usage.');
|
|
133
|
+
const request = load(requestPath);
|
|
134
|
+
const config = load((mode === 'inspect' ? third : fourth) || '.agents/planning.json');
|
|
135
|
+
const result = mode === 'inspect' ? inspect(request, config) : apply(request, load(third), config);
|
|
136
|
+
console.log(JSON.stringify(result));
|
|
137
|
+
if (result.status && result.status !== 'synchronized') process.exitCode = 1;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.log(JSON.stringify({ status: 'pending', reason: error.message, nextAction: 'Inspect the affected record and resolve this operation before claiming synchronization.' }));
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -188,6 +188,12 @@ actual acknowledgement.
|
|
|
188
188
|
|
|
189
189
|
At each work checkpoint, reconcile tracking in this order:
|
|
190
190
|
|
|
191
|
+
For a join/resume checkpoint with a selected Project and working `gh`, MUST use
|
|
192
|
+
the [executable Project checkpoint](github-project-checkpoint.md). Read its
|
|
193
|
+
inspection and handle current discussion before applying it; do not manually
|
|
194
|
+
publish synchronization-success text on that route. Other checkpoints and
|
|
195
|
+
authorized alternatives retain the rules below.
|
|
196
|
+
|
|
191
197
|
1. **Establish scope.** Reuse the established backend. Establish Project selection
|
|
192
198
|
separately from the backend, using configuration, project requirements or
|
|
193
199
|
explicit direction: selected/required, none, or unresolved. Do not infer none
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Execute a selected-Project resume checkpoint
|
|
2
|
+
|
|
3
|
+
For a join/resume checkpoint with a selected GitHub Project and working `gh`,
|
|
4
|
+
planning and `contribute` MUST use the bundled
|
|
5
|
+
`scripts/github-project-checkpoint.mjs` operation. Do not manually publish a
|
|
6
|
+
Project synchronization-success claim on this route. The operation reads the
|
|
7
|
+
records/discussion, updates the two items, verifies their saved fields and only
|
|
8
|
+
then generates the task's synchronization result. It does not allocate owners,
|
|
9
|
+
approve a plan, acknowledge another person or complete the increment.
|
|
10
|
+
|
|
11
|
+
Resolve the script relative to this installed planning skill, including when
|
|
12
|
+
called from `contribute`. Use the existing authenticated `gh` account and saved
|
|
13
|
+
project configuration; no new grants, account changes or tokens are required by
|
|
14
|
+
the operation. Local and GitHub Issues-only tracking keep their existing paths.
|
|
15
|
+
|
|
16
|
+
## Inspect, decide, apply
|
|
17
|
+
|
|
18
|
+
Create a request from the actual records and Project fields. IDs below are
|
|
19
|
+
placeholders. Preserve each record's own verified agent/session; do not copy the
|
|
20
|
+
task contributor onto the increment. The status option must be the existing
|
|
21
|
+
In progress equivalent, never Done.
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"outcome": 1,
|
|
26
|
+
"increment": {"issue": 2, "itemId": "INCREMENT_ITEM_ID", "agent": "verified increment owner/session"},
|
|
27
|
+
"task": {"issue": 4, "itemId": "TASK_ITEM_ID", "agent": "verified task owner/session"},
|
|
28
|
+
"statusFieldId": "STATUS_FIELD_ID",
|
|
29
|
+
"agentFieldId": "AGENT_FIELD_ID",
|
|
30
|
+
"statusOptionId": "IN_PROGRESS_OPTION_ID",
|
|
31
|
+
"nextAction": "the actual next step in this task"
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
1. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs inspect request.json`.
|
|
36
|
+
The default configuration is `.agents/planning.json` in the project. An
|
|
37
|
+
existing project-declared configuration path may be supplied as the final
|
|
38
|
+
argument. Save the returned JSON in a permitted working location.
|
|
39
|
+
2. Read that inspection, including the increment discussion, current revision,
|
|
40
|
+
dependencies and claims. Acknowledge relevant handoffs through the authorized
|
|
41
|
+
channel and resolve overlap or material changes under the existing rules.
|
|
42
|
+
The operation reading a comment does not mean its recipient acknowledged it.
|
|
43
|
+
If that work changes these records, inspect again before applying.
|
|
44
|
+
3. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs apply request.json inspection.json`.
|
|
45
|
+
Supply the same explicit configuration path last when one was used above.
|
|
46
|
+
The operation rechecks inspected context, updates only task/increment
|
|
47
|
+
Status/Agent, reads back the actual fields, then publishes generated task
|
|
48
|
+
progress and reads it back. It preserves Horizon and the rest of the brief.
|
|
49
|
+
The current brief needs a literal `Next action:` line; if absent, maintain
|
|
50
|
+
that field truthfully before inspecting again. Do not erase existing content.
|
|
51
|
+
|
|
52
|
+
Use direct commands with literal paths supported by the runtime permissions.
|
|
53
|
+
Do not infer unavailable authority from one rejected compound command when an
|
|
54
|
+
already-authorized direct invocation remains available.
|
|
55
|
+
|
|
56
|
+
## Read the result
|
|
57
|
+
|
|
58
|
+
- `synchronized`: the requested fields and generated task update were read back.
|
|
59
|
+
This establishes this checkpoint only, not task or increment completion.
|
|
60
|
+
- `changed-context`: inspect the returned facts and resolve the change. A body
|
|
61
|
+
changed before publication stays untouched, even if fields already changed.
|
|
62
|
+
- `pending`: keep the precise affected operation and next action visible. A
|
|
63
|
+
partial or ambiguous field result cannot generate synchronization success.
|
|
64
|
+
The operation reads saved state before resolving uncertain writes and never
|
|
65
|
+
automatically repeats a mutation. If the pending note itself could not be
|
|
66
|
+
saved, its output does not prove the issue was updated.
|
|
67
|
+
|
|
68
|
+
Do not treat an exit code, assignment or local note as a saved-field result.
|
|
69
|
+
Keep useful authorized issue work moving while Project work is pending. If
|
|
70
|
+
`gh` or this operation is unavailable, use other already-authorized capabilities
|
|
71
|
+
under the coordination protocol, preserving the same truthful claim dependency.
|
|
72
|
+
Do not request broader access while an existing authorized route works, or
|
|
73
|
+
claim the executable operation enforced an untested browser/manual path.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const itemQuery = `query($ids:[ID!]!,$agentFieldId:ID!){nodes(ids:$ids){... on ProjectV2Item{
|
|
7
|
+
id project{id url} content{... on Issue{number url parent{number url}}}
|
|
8
|
+
fieldValues(first:100){nodes{... on ProjectV2ItemFieldSingleSelectValue{
|
|
9
|
+
optionId name field{... on ProjectV2SingleSelectField{id}}
|
|
10
|
+
} ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id}}}}
|
|
11
|
+
pageInfo{hasNextPage}}
|
|
12
|
+
}} agentField:node(id:$agentFieldId){... on ProjectV2Field{dataType}}}`;
|
|
13
|
+
const start = '<!-- outcome-led-planning:project-checkpoint:start -->';
|
|
14
|
+
const end = '<!-- outcome-led-planning:project-checkpoint:end -->';
|
|
15
|
+
const load = path => JSON.parse(readFileSync(path, 'utf8'));
|
|
16
|
+
const requireValue = (ok, message) => { if (!ok) throw new Error(message); };
|
|
17
|
+
|
|
18
|
+
function call(args, input) {
|
|
19
|
+
const result = spawnSync('gh', args, { input, encoding: 'utf8', timeout: 60000 });
|
|
20
|
+
return { ...result, failure: result.error?.message || (result.status !== 0 ? result.stderr.trim() || `gh exited ${result.status}` : '') };
|
|
21
|
+
}
|
|
22
|
+
function json(args, input) {
|
|
23
|
+
const result = call(args, input);
|
|
24
|
+
if (result.failure) throw new Error(result.failure);
|
|
25
|
+
const value = JSON.parse(result.stdout);
|
|
26
|
+
if (value.errors?.length) throw new Error(value.errors.map(error => error.message).join('; '));
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
function scope(request, config) {
|
|
30
|
+
requireValue(config.version === 1 && config.backend === 'github', 'This operation requires the saved GitHub backend.');
|
|
31
|
+
const repository = config.github?.repository;
|
|
32
|
+
requireValue(typeof repository === 'string' && /^[\w.-]+\/[\w.-]+$/.test(repository), 'Invalid configured GitHub repository.');
|
|
33
|
+
const url = new URL(config.github?.projectUrl);
|
|
34
|
+
const match = url.pathname.match(/^\/(?:users|orgs)\/([\w.-]+)\/projects\/([1-9]\d*)(?:\/views\/[1-9]\d*)?\/?$/);
|
|
35
|
+
requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && match && !url.username && !url.password, 'A selected GitHub Project URL is required.');
|
|
36
|
+
requireValue([request.outcome, request.increment?.issue, request.task?.issue].every(n => Number.isSafeInteger(n) && n > 0), 'Outcome, increment and task issue numbers are required.');
|
|
37
|
+
requireValue(new Set([request.outcome, request.increment.issue, request.task.issue]).size === 3, 'The three issue roles must be distinct.');
|
|
38
|
+
for (const value of [request.increment.itemId, request.task.itemId, request.statusFieldId, request.agentFieldId, request.statusOptionId]) {
|
|
39
|
+
requireValue(typeof value === 'string' && value.length > 0 && !/[\r\n\0]/.test(value), 'Existing item, field and option IDs are required.');
|
|
40
|
+
}
|
|
41
|
+
requireValue(request.increment.itemId !== request.task.itemId && request.statusFieldId !== request.agentFieldId, 'Item and field roles must be distinct.');
|
|
42
|
+
return { repository, owner: match[1], number: match[2], projectUrl: `https://github.com${url.pathname.split('/views/')[0].replace(/\/$/, '')}` };
|
|
43
|
+
}
|
|
44
|
+
function issue(number, bound) {
|
|
45
|
+
return json(['issue', 'view', String(number), '--repo', bound.repository, '--json', 'number,url,body,state,assignees,labels,comments']);
|
|
46
|
+
}
|
|
47
|
+
function items(request, bound, projectId) {
|
|
48
|
+
const response = json(['api', 'graphql', '--input', '-'], JSON.stringify({ query: itemQuery, variables: { ids: [request.increment.itemId, request.task.itemId], agentFieldId: request.agentFieldId } }));
|
|
49
|
+
requireValue(response.data.agentField?.dataType === 'TEXT', 'The Agent field must be a text field.');
|
|
50
|
+
return response.data.nodes.map((item, index) => {
|
|
51
|
+
const record = index === 0 ? request.increment : request.task;
|
|
52
|
+
const parent = index === 0 ? request.outcome : request.increment.issue;
|
|
53
|
+
const expected = `https://github.com/${bound.repository}/issues/${record.issue}`;
|
|
54
|
+
requireValue(item && item.id === record.itemId && item.project.id === projectId && item.project.url === bound.projectUrl, 'Project item does not belong to the selected Project.');
|
|
55
|
+
requireValue(item.content?.url.toLowerCase() === expected.toLowerCase() && item.content.parent?.number === parent && item.content.parent.url.toLowerCase() === `https://github.com/${bound.repository}/issues/${parent}`.toLowerCase(), 'Current native issue parent or repository does not match the requested hierarchy.');
|
|
56
|
+
requireValue(!item.fieldValues.pageInfo.hasNextPage, 'Project field response is incomplete; inspect the remaining fields before applying.');
|
|
57
|
+
return { id: item.id, issue: record.issue, fields: Object.fromEntries(item.fieldValues.nodes.filter(value => value.field?.id).map(value => [value.field.id, 'text' in value ? { text: value.text } : { optionId: value.optionId, name: value.name }]).sort(([a], [b]) => a.localeCompare(b))) };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function inspect(request, config) {
|
|
61
|
+
const bound = scope(request, config);
|
|
62
|
+
const actor = json(['api', 'user']).login;
|
|
63
|
+
const project = json(['project', 'view', bound.number, '--owner', bound.owner, '--format', 'json']);
|
|
64
|
+
requireValue(project.url === bound.projectUrl && !project.closed, 'Selected Project is unavailable or closed.');
|
|
65
|
+
const allFields = json(['project', 'field-list', bound.number, '--owner', bound.owner, '--format', 'json']).fields;
|
|
66
|
+
const status = allFields.find(field => field.id === request.statusFieldId);
|
|
67
|
+
const agent = allFields.find(field => field.id === request.agentFieldId);
|
|
68
|
+
const option = status?.options?.find(value => value.id === request.statusOptionId);
|
|
69
|
+
requireValue(status?.name === 'Status' && status.type === 'ProjectV2SingleSelectField' && agent?.name === 'Agent' && agent.type === 'ProjectV2Field' && option, 'Requested fields must be the selected Project Status and Agent fields.');
|
|
70
|
+
requireValue(option.name.replace(/[-_\s]/g, '').toLowerCase() === 'inprogress', 'A resume checkpoint requires the existing In progress status option.');
|
|
71
|
+
const records = { outcome: issue(request.outcome, bound), increment: issue(request.increment.issue, bound), task: issue(request.task.issue, bound) };
|
|
72
|
+
requireValue(Object.values(records).every(record => record.state === 'OPEN'), 'This resume operation requires open outcome, increment and task records.');
|
|
73
|
+
return { context: { actor, repository: bound.repository, project: { id: project.id, url: project.url }, fields: { status: { id: status.id, option }, agent: { id: agent.id } }, issues: records, items: items(request, bound, project.id) } };
|
|
74
|
+
}
|
|
75
|
+
function bodyFor(body, request, status, observed, reason) {
|
|
76
|
+
// shortcut: only the observed literal Next action line; extend when an adopter needs another brief format.
|
|
77
|
+
requireValue(/^Next action:[^\r\n]*/m.test(body), 'Task brief needs its current literal Next action line; no body changes were made.');
|
|
78
|
+
const first = body.indexOf(start);
|
|
79
|
+
const last = body.indexOf(end);
|
|
80
|
+
requireValue((first === -1 && last === -1) || (first >= 0 && last > first && body.indexOf(start, first + 1) === -1 && body.indexOf(end, last + 1) === -1), 'Existing checkpoint block is malformed; preserve the body and repair that block first.');
|
|
81
|
+
let preserved = first < 0 ? body : body.slice(0, first) + body.slice(last + end.length);
|
|
82
|
+
const next = status === 'synchronized' ? request.nextAction : `Resolve the pending Project checkpoint: ${reason}`;
|
|
83
|
+
preserved = preserved.replace(/^Next action:[^\r\n]*/m, () => `Next action: ${next}`);
|
|
84
|
+
const rows = status === 'synchronized' ? observed.map(item => `- Issue #${item.issue}: Status ${JSON.stringify(item.fields[request.statusFieldId].name)}; Agent ${JSON.stringify(item.fields[request.agentFieldId].text)}.`).join('\n') : `Pending: ${reason}`;
|
|
85
|
+
return `${preserved.trimEnd()}\n\n${start}\n## Current Project checkpoint\n\nResult: ${status}\n${rows}\n${end}\n`;
|
|
86
|
+
}
|
|
87
|
+
function apply(request, inspection, config) {
|
|
88
|
+
for (const value of [request.increment?.agent, request.task?.agent, request.nextAction]) requireValue(typeof value === 'string' && value.trim() && !/[\r\n\0]/.test(value), 'Verified agent/session values and one next action are required.');
|
|
89
|
+
const current = inspect(request, config);
|
|
90
|
+
if (!isDeepStrictEqual(current.context, inspection.context)) return { status: 'changed-context', record: request.task.issue, nextAction: 'Read the returned current context and resolve its changes before applying.', ...current };
|
|
91
|
+
const bound = scope(request, config);
|
|
92
|
+
const projectId = current.context.project.id;
|
|
93
|
+
const originalBody = current.context.issues.task.body;
|
|
94
|
+
bodyFor(originalBody, request, 'pending', [], 'validation');
|
|
95
|
+
let observed = current.context.items;
|
|
96
|
+
let failure = '';
|
|
97
|
+
let operation = '';
|
|
98
|
+
try {
|
|
99
|
+
for (const record of [request.increment, request.task]) {
|
|
100
|
+
for (const [field, flag, value] of [[request.agentFieldId, '--text', record.agent], [request.statusFieldId, '--single-select-option-id', request.statusOptionId]]) {
|
|
101
|
+
operation = `Issue #${record.issue}, item ${record.itemId}, ${field === request.agentFieldId ? 'Agent' : 'Status'} field ${field}`;
|
|
102
|
+
const result = call(['project', 'item-edit', '--id', record.itemId, '--field-id', field, '--project-id', projectId, flag, value, '--format', 'json']);
|
|
103
|
+
if (result.failure) {
|
|
104
|
+
observed = items(request, bound, projectId);
|
|
105
|
+
const saved = observed.find(item => item.id === record.itemId).fields[field];
|
|
106
|
+
if ((flag === '--text' ? saved?.text : saved?.optionId) !== value) throw new Error(result.failure);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
operation = `Saved Status/Agent readback for issues #${request.increment.issue} and #${request.task.issue}`;
|
|
111
|
+
observed = items(request, bound, projectId);
|
|
112
|
+
for (const record of [request.increment, request.task]) {
|
|
113
|
+
const saved = observed.find(item => item.id === record.itemId).fields;
|
|
114
|
+
requireValue(saved[request.agentFieldId]?.text === record.agent && saved[request.statusFieldId]?.optionId === request.statusOptionId, `Saved Project fields for issue #${record.issue} do not match the requested state.`);
|
|
115
|
+
}
|
|
116
|
+
} catch (error) { failure = `${operation}: ${error.message}`; }
|
|
117
|
+
const freshBody = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
|
|
118
|
+
if (freshBody !== originalBody) return { status: 'changed-context', record: request.task.issue, lastObservedFields: observed, nextAction: 'Preserve the changed task body; inspect it before publishing any checkpoint result.' };
|
|
119
|
+
const status = failure ? 'pending' : 'synchronized';
|
|
120
|
+
const body = bodyFor(freshBody, request, status, observed, failure);
|
|
121
|
+
const written = call(['issue', 'edit', String(request.task.issue), '--repo', bound.repository, '--body-file', '-'], body);
|
|
122
|
+
const saved = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
|
|
123
|
+
if (saved !== body) return { status: 'pending', record: request.task.issue, reason: written.failure || 'Saved issue body does not match the checkpoint update.', lastObservedFields: observed, nextAction: 'Read the saved issue body before deciding on another write.' };
|
|
124
|
+
return { status, record: request.task.issue, lastObservedFields: observed, ...(failure ? { reason: failure, nextAction: 'Resolve the pending operation before claiming synchronization.' } : {}) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (process.argv[2] === '--help') {
|
|
128
|
+
console.log('Inspect: node github-project-checkpoint.mjs inspect REQUEST.json [CONFIG.json]\nApply: node github-project-checkpoint.mjs apply REQUEST.json INSPECTION.json [CONFIG.json]');
|
|
129
|
+
} else {
|
|
130
|
+
try {
|
|
131
|
+
const [mode, requestPath, third, fourth] = process.argv.slice(2);
|
|
132
|
+
requireValue(mode === 'inspect' || mode === 'apply', 'Expected inspect or apply; use --help for usage.');
|
|
133
|
+
const request = load(requestPath);
|
|
134
|
+
const config = load((mode === 'inspect' ? third : fourth) || '.agents/planning.json');
|
|
135
|
+
const result = mode === 'inspect' ? inspect(request, config) : apply(request, load(third), config);
|
|
136
|
+
console.log(JSON.stringify(result));
|
|
137
|
+
if (result.status && result.status !== 'synchronized') process.exitCode = 1;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.log(JSON.stringify({ status: 'pending', reason: error.message, nextAction: 'Inspect the affected record and resolve this operation before claiming synchronization.' }));
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
}
|
|
142
|
+
}
|