@windyroad/itil 3.4.0-preview.1367 → 3.4.0-preview.1369
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 +26 -10
- package/skills/outcome-led-planning/references/github-project-checkpoint.md +137 -0
- package/skills/outcome-led-planning/scripts/github-project-checkpoint.mjs +208 -0
- package/skills-codex/outcome-led-planning/references/github-coordination.md +26 -10
- package/skills-codex/outcome-led-planning/references/github-project-checkpoint.md +137 -0
- package/skills-codex/outcome-led-planning/scripts/github-project-checkpoint.mjs +208 -0
package/package.json
CHANGED
|
@@ -156,7 +156,7 @@ Use this contribution lifecycle:
|
|
|
156
156
|
|
|
157
157
|
A starting battle plan exposes likely work, dependencies and parallel opportunities without precreating, assigning or reserving every future task. The increment owner keeps acceptance, sequence and integration clear and resolves overlap/dependencies. Explicit assignments and handoffs remain valid. Two agents sharing an account are distinct contributors. Checklists suffice for routine steps; do not create a task for every command. Task creation within assessed scope requires no fresh human approval.
|
|
158
158
|
|
|
159
|
-
**
|
|
159
|
+
**One contribution claim is a ceiling, not a retention requirement.** A blocked assignment still counts until its release is verified. For an unfinished task, when no useful authorized in-task step remains, hand over the remaining gap, remove your assignment and read back its removal; do not retain it merely because its dependency is unresolved. Preserve genuine assigned watcher duties; routine discussion reads alone do not justify retaining an exhausted reservation. Finish, explicitly hand over or release the current assignment before claiming another. Do not reserve queued tasks. Outcome or increment coordination ownership does not reserve contribution tasks. Agents sharing an account remain distinct through their recorded agent/session identities.
|
|
160
160
|
|
|
161
161
|
Before declaring a contribution ready to start, complete this claim receipt: **no other contribution assignment held; parent increment; current assessed plan revision; non-overlapping scope; dependencies (or explicitly none); actual account; distinct agent/session; saved claim and reciprocal-link readback; competing-claim recheck.** Record all fields on the task, even when some are already known in conversation. Assignment does not increase authority. When explaining a proposed claim, make these required fields and the before/after overlap checks explicit.
|
|
162
162
|
|
|
@@ -188,15 +188,31 @@ actual acknowledgement.
|
|
|
188
188
|
|
|
189
189
|
At each work checkpoint, reconcile tracking in this order:
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
191
|
+
When the affected records include an outcome, current Now, provisional Next and
|
|
192
|
+
a task, and `gh` works, MUST first use the [read-only hierarchy inspector](github-project-checkpoint.md#inspect-the-hierarchy).
|
|
193
|
+
Construct its request from those actual records and consume its findings before
|
|
194
|
+
claiming completeness; identical draft/remote text is not that comparison.
|
|
195
|
+
Do not manufacture a Next issue for this route. Its configuration report is one
|
|
196
|
+
source for the selection decision below, not proof of capability or acceptance.
|
|
197
|
+
|
|
198
|
+
1. **Establish selection before checking Project capability.** Reuse the
|
|
199
|
+
established backend. From configuration, project requirements or explicit
|
|
200
|
+
direction, choose the applicable branch:
|
|
201
|
+
- **Selected/required:** verify the repository, Project and account, then
|
|
202
|
+
inspect its saved setup and applicable operations. For join/resume of an
|
|
203
|
+
active open task with working `gh`, MUST use the [executable Project checkpoint](github-project-checkpoint.md).
|
|
204
|
+
Read its inspection and handle current discussion before applying it;
|
|
205
|
+
do not manually publish synchronization-success text on that route.
|
|
206
|
+
If an applicable operation is unavailable, retain its exact pending state
|
|
207
|
+
under the recovery rules below. An unavailable API or bootstrap default
|
|
208
|
+
does not prove a missing view, field or membership.
|
|
209
|
+
- **Established none:** continue issue-side reconciliation without Project
|
|
210
|
+
setup, fields or synchronization.
|
|
211
|
+
- **Unresolved:** inspect available configuration, requirements, direction and
|
|
212
|
+
issue records. Record the selection gap and keep Project work conditional;
|
|
213
|
+
there is no established mirror operation to report pending or unsynchronized.
|
|
214
|
+
Omitted information and API availability establish neither selection nor
|
|
215
|
+
an explicit choice to use no Project.
|
|
200
216
|
2. **Inspect each affected record.** Read each outcome, increment and task
|
|
201
217
|
against the minimum-information table above. Preserve its own owner/account,
|
|
202
218
|
applicable agent/session, revision and acceptance; mark missing required
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Inspect and reconcile GitHub records
|
|
2
|
+
|
|
3
|
+
Resolve `scripts/github-project-checkpoint.mjs` relative to this installed
|
|
4
|
+
planning skill, including when called from `contribute`. Use the existing
|
|
5
|
+
authenticated `gh` account and saved project configuration. The default is
|
|
6
|
+
`.agents/planning.json`; an existing project-declared configuration path may be
|
|
7
|
+
supplied last. These operations require no new grants, account changes or tokens.
|
|
8
|
+
Local tracking keeps its existing path.
|
|
9
|
+
|
|
10
|
+
## Inspect the hierarchy
|
|
11
|
+
|
|
12
|
+
When reconciling an outcome, current Now, an affected provisional Next and a
|
|
13
|
+
task together with working `gh`, planning and `contribute` MUST first use the
|
|
14
|
+
read-only hierarchy inspector. Supply those actual distinct issue numbers;
|
|
15
|
+
do not invent a Next issue to satisfy the command. Other record sets retain
|
|
16
|
+
their existing coordination path.
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"outcome": 1,
|
|
21
|
+
"increment": {"issue": 2},
|
|
22
|
+
"next": {"issue": 3},
|
|
23
|
+
"task": {"issue": 4}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs inspect-hierarchy request.json`.
|
|
28
|
+
This reads the four actual issues and their returned comments. It writes
|
|
29
|
+
nothing and makes no Project or native-link request. Read the raw records and
|
|
30
|
+
consume `findings` before claiming that the records are correct:
|
|
31
|
+
|
|
32
|
+
- `projectSelection` reports configuration facts. `not-recorded` means selection
|
|
33
|
+
is unresolved from that configuration, not an explicit choice to use no
|
|
34
|
+
Project. `recorded` does not prove capability; consider actual project
|
|
35
|
+
requirements and explicit direction too. Preserve invalid configuration.
|
|
36
|
+
- Each record's `inspection` reports literal owner, revision, acceptance and
|
|
37
|
+
Parent candidates with raw line evidence. `candidate` is text, not verified
|
|
38
|
+
ownership or passed acceptance. `explicitly-unresolved` preserves a recorded
|
|
39
|
+
unknown. Empty/missing literals, unsupported syntax and multiple candidates
|
|
40
|
+
remain unestablished by this inspector; they do not prove semantic information
|
|
41
|
+
is absent or that the issue is invalid. Inspect the actual prose/history
|
|
42
|
+
rather than forcing a new format or choosing a historical value.
|
|
43
|
+
- `textLinks` compares the three requested textual relationships. A mention is
|
|
44
|
+
observed text, not proof of a current native relationship. Inspect both sides
|
|
45
|
+
and repair known reciprocal gaps under the existing coordination rules.
|
|
46
|
+
- `capabilities` are `not-probed`; do not turn that into an outage or native-link
|
|
47
|
+
absence. `inspection-unavailable` means the read/report operation failed,
|
|
48
|
+
not that a Project mirror is pending.
|
|
49
|
+
|
|
50
|
+
A successful command produces a diagnostic report, never a correctness or
|
|
51
|
+
synchronization verdict. Identical drafts and saved bodies do not establish
|
|
52
|
+
completeness. Before claiming completeness, account for every finding through
|
|
53
|
+
actual semantic review, authorized repair with saved readback where needed, or
|
|
54
|
+
honest unresolved recording in the existing plan or record. Follow the selection
|
|
55
|
+
finding's conditional next action, which targets the existing outcome issue
|
|
56
|
+
for saving and reading back a still-unresolved gap. Reuse an already saved gap
|
|
57
|
+
without an unchanged write. Configuration alone may leave the choice unresolved
|
|
58
|
+
while requirements or explicit direction establish it. Unsupported
|
|
59
|
+
syntax does not force normalization or another artifact.
|
|
60
|
+
Apply the existing per-record rules to known gaps, marking missing
|
|
61
|
+
required information unresolved rather than inventing it; preserve completed
|
|
62
|
+
acceptance and attribution. Read back authorized changes and compare reciprocal
|
|
63
|
+
links before reporting reconciliation. Unsupported syntax needs actual review,
|
|
64
|
+
not automatic normalization. Semantic acceptance remains with the caller and
|
|
65
|
+
required independent review.
|
|
66
|
+
|
|
67
|
+
## Synchronize selected Project fields
|
|
68
|
+
|
|
69
|
+
For a join/resume checkpoint on an active open task with a selected GitHub Project and working `gh`,
|
|
70
|
+
planning and `contribute` MUST use the bundled
|
|
71
|
+
`scripts/github-project-checkpoint.mjs` operation. Do not manually publish a
|
|
72
|
+
Project synchronization-success claim on this route. The operation reads the
|
|
73
|
+
records/discussion, updates the two items, verifies their saved fields and only
|
|
74
|
+
then generates the task's synchronization result. It does not allocate owners,
|
|
75
|
+
approve a plan, acknowledge another person or complete the increment.
|
|
76
|
+
|
|
77
|
+
This mutation route applies to its open outcome, increment and active task.
|
|
78
|
+
GitHub Issues-only tracking uses no Project field mutations.
|
|
79
|
+
|
|
80
|
+
## Inspect, decide, apply
|
|
81
|
+
|
|
82
|
+
Create a request from the actual records and Project fields. IDs below are
|
|
83
|
+
placeholders. Preserve each record's own verified agent/session; do not copy the
|
|
84
|
+
task contributor onto the increment. The status option must be the existing
|
|
85
|
+
In progress equivalent, never Done.
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"outcome": 1,
|
|
90
|
+
"increment": {"issue": 2, "itemId": "INCREMENT_ITEM_ID", "agent": "verified increment owner/session"},
|
|
91
|
+
"task": {"issue": 4, "itemId": "TASK_ITEM_ID", "agent": "verified task owner/session"},
|
|
92
|
+
"statusFieldId": "STATUS_FIELD_ID",
|
|
93
|
+
"agentFieldId": "AGENT_FIELD_ID",
|
|
94
|
+
"statusOptionId": "IN_PROGRESS_OPTION_ID",
|
|
95
|
+
"nextAction": "the actual next step in this task"
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
1. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs inspect request.json`.
|
|
100
|
+
The default configuration is `.agents/planning.json` in the project. An
|
|
101
|
+
existing project-declared configuration path may be supplied as the final
|
|
102
|
+
argument. Save the returned JSON in a permitted working location.
|
|
103
|
+
2. Read that inspection, including the increment discussion, current revision,
|
|
104
|
+
dependencies and claims. Acknowledge relevant handoffs through the authorized
|
|
105
|
+
channel and resolve overlap or material changes under the existing rules.
|
|
106
|
+
The operation reading a comment does not mean its recipient acknowledged it.
|
|
107
|
+
If that work changes these records, inspect again before applying.
|
|
108
|
+
3. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs apply request.json inspection.json`.
|
|
109
|
+
Supply the same explicit configuration path last when one was used above.
|
|
110
|
+
The operation rechecks inspected context, updates only task/increment
|
|
111
|
+
Status/Agent, reads back the actual fields, then publishes generated task
|
|
112
|
+
progress and reads it back. It preserves Horizon and the rest of the brief.
|
|
113
|
+
The current brief needs a literal `Next action:` line; if absent, maintain
|
|
114
|
+
that field truthfully before inspecting again. Do not erase existing content.
|
|
115
|
+
|
|
116
|
+
Use direct commands with literal paths supported by the runtime permissions.
|
|
117
|
+
Do not infer unavailable authority from one rejected compound command when an
|
|
118
|
+
already-authorized direct invocation remains available.
|
|
119
|
+
|
|
120
|
+
## Read the result
|
|
121
|
+
|
|
122
|
+
- `synchronized`: the requested fields and generated task update were read back.
|
|
123
|
+
This establishes this checkpoint only, not task or increment completion.
|
|
124
|
+
- `changed-context`: inspect the returned facts and resolve the change. A body
|
|
125
|
+
changed before publication stays untouched, even if fields already changed.
|
|
126
|
+
- `pending`: keep the precise affected operation and next action visible. A
|
|
127
|
+
partial or ambiguous field result cannot generate synchronization success.
|
|
128
|
+
The operation reads saved state before resolving uncertain writes and never
|
|
129
|
+
automatically repeats a mutation. If the pending note itself could not be
|
|
130
|
+
saved, its output does not prove the issue was updated.
|
|
131
|
+
|
|
132
|
+
Do not treat an exit code, assignment or local note as a saved-field result.
|
|
133
|
+
Keep useful authorized issue work moving while Project work is pending. If
|
|
134
|
+
`gh` or this operation is unavailable, use other already-authorized capabilities
|
|
135
|
+
under the coordination protocol, preserving the same truthful claim dependency.
|
|
136
|
+
Do not request broader access while an existing authorized route works, or
|
|
137
|
+
claim the executable operation enforced an untested browser/manual path.
|
|
@@ -0,0 +1,208 @@
|
|
|
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 repositoryFor(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' && /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[\w.-]+$/.test(repository) && !['.', '..'].includes(repository.split('/')[1]), 'Invalid configured GitHub repository.');
|
|
33
|
+
return repository;
|
|
34
|
+
}
|
|
35
|
+
function scope(request, config) {
|
|
36
|
+
const repository = repositoryFor(config);
|
|
37
|
+
const url = new URL(config.github?.projectUrl);
|
|
38
|
+
const match = url.pathname.match(/^\/(?:users|orgs)\/([\w.-]+)\/projects\/([1-9]\d*)(?:\/views\/[1-9]\d*)?\/?$/);
|
|
39
|
+
requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && match && !url.username && !url.password, 'A selected GitHub Project URL is required.');
|
|
40
|
+
requireValue([request.outcome, request.increment?.issue, request.task?.issue].every(n => Number.isSafeInteger(n) && n > 0), 'Outcome, increment and task issue numbers are required.');
|
|
41
|
+
requireValue(new Set([request.outcome, request.increment.issue, request.task.issue]).size === 3, 'The three issue roles must be distinct.');
|
|
42
|
+
for (const value of [request.increment.itemId, request.task.itemId, request.statusFieldId, request.agentFieldId, request.statusOptionId]) {
|
|
43
|
+
requireValue(typeof value === 'string' && value.length > 0 && !/[\r\n\0]/.test(value), 'Existing item, field and option IDs are required.');
|
|
44
|
+
}
|
|
45
|
+
requireValue(request.increment.itemId !== request.task.itemId && request.statusFieldId !== request.agentFieldId, 'Item and field roles must be distinct.');
|
|
46
|
+
return { repository, owner: match[1], number: match[2], projectUrl: `https://github.com${url.pathname.split('/views/')[0].replace(/\/$/, '')}` };
|
|
47
|
+
}
|
|
48
|
+
function issue(number, bound) {
|
|
49
|
+
return json(['issue', 'view', String(number), '--repo', bound.repository, '--json', 'number,url,body,state,assignees,labels,comments']);
|
|
50
|
+
}
|
|
51
|
+
function briefField(body, pattern, cue) {
|
|
52
|
+
// shortcut: observed literal brief fields only; extend when an actual brief needs another syntax.
|
|
53
|
+
const lines = body.split(/\r?\n/).map((text, index) => ({ line: index + 1, text }));
|
|
54
|
+
const matches = lines.flatMap(line => {
|
|
55
|
+
const match = line.text.match(pattern);
|
|
56
|
+
return match ? [{ ...line, value: match[1].trim() }] : [];
|
|
57
|
+
});
|
|
58
|
+
const literalEvidence = matches.map(({ line, text }) => ({ line, text }));
|
|
59
|
+
if (matches.length > 1) return { state: 'multiple-candidates', evidence: literalEvidence };
|
|
60
|
+
if (matches.length === 1) {
|
|
61
|
+
const { value } = matches[0];
|
|
62
|
+
return { state: !value ? 'empty' : /^(?:unresolved|unknown|tbd|unclaimed)(?:\b|$)/i.test(value) ? 'explicitly-unresolved' : 'candidate', value, evidence: literalEvidence };
|
|
63
|
+
}
|
|
64
|
+
const evidence = lines.filter(line => cue.test(line.text));
|
|
65
|
+
return { state: evidence.length ? 'unsupported-format' : 'literal-field-missing', evidence };
|
|
66
|
+
}
|
|
67
|
+
function textReference(body, number, repository) {
|
|
68
|
+
const escaped = repository.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
69
|
+
const reference = new RegExp(`(?:(?<![\\w./#:-])#${number}\\b|https://github\\.com/${escaped}/issues/${number}(?![\\w/]))`, 'i');
|
|
70
|
+
const evidence = body.split(/\r?\n/).flatMap((text, index) => reference.test(text) ? [{ line: index + 1, text }] : []);
|
|
71
|
+
return { observed: evidence.length > 0, evidence };
|
|
72
|
+
}
|
|
73
|
+
function inspectHierarchy(request, config) {
|
|
74
|
+
const repository = repositoryFor(config);
|
|
75
|
+
const roles = { outcome: request?.outcome, increment: request?.increment?.issue, next: request?.next?.issue, task: request?.task?.issue };
|
|
76
|
+
requireValue(Object.values(roles).every(n => Number.isSafeInteger(n) && n > 0) && new Set(Object.values(roles)).size === 4, 'Four distinct outcome, increment, next and task issue numbers are required.');
|
|
77
|
+
const projectSelection = { state: 'not-recorded', source: 'configuration' };
|
|
78
|
+
if (Object.hasOwn(config.github, 'projectUrl')) {
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(config.github.projectUrl);
|
|
81
|
+
requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && !url.username && !url.password && !url.search && !url.hash && /^\/(?:users|orgs)\/[\w.-]+\/projects\/[1-9]\d*(?:\/views\/[1-9]\d*)?\/?$/.test(url.pathname), 'Invalid recorded Project URL.');
|
|
82
|
+
Object.assign(projectSelection, { state: 'recorded', url: `https://github.com${url.pathname.split('/views/')[0].replace(/\/$/, '')}` });
|
|
83
|
+
} catch { projectSelection.state = 'invalid'; }
|
|
84
|
+
}
|
|
85
|
+
const records = {};
|
|
86
|
+
const findings = projectSelection.state === 'recorded' ? [] : [{ issue: roles.outcome, field: 'projectSelection', state: projectSelection.state, nextAction: `Establish selection from applicable project requirements and explicit direction. If it remains unresolved, save that selection gap and conditional Project work on outcome #${roles.outcome} and read back its saved body before claiming reconciliation complete. Reuse an already saved gap without an unchanged write.` }];
|
|
87
|
+
for (const [role, number] of Object.entries(roles)) {
|
|
88
|
+
const record = issue(number, { repository });
|
|
89
|
+
requireValue(record.number === number && record.url?.toLowerCase() === `https://github.com/${repository}/issues/${number}`.toLowerCase() && typeof record.body === 'string', 'Returned issue does not match its requested repository/number or brief.');
|
|
90
|
+
const inspection = {
|
|
91
|
+
owner: briefField(record.body, /^\s*(?:(?:Outcome|Increment|Task) )?Owner:\s*(.*)$/i, /\bowner\b/i),
|
|
92
|
+
revision: briefField(record.body, /^\s*Revision:\s*(.*)$/i, /\brevision\b/i),
|
|
93
|
+
acceptance: briefField(record.body, /^\s*(?:Tentative )?Acceptance(?: criteria| evidence)?:\s*(.*)$/i, /\bacceptance\b/i),
|
|
94
|
+
parent: briefField(record.body, /^\s*Parent(?: outcome| increment)?:\s*(.*)$/i, /\bparent\b/i)
|
|
95
|
+
};
|
|
96
|
+
records[role] = { ...record, inspection };
|
|
97
|
+
for (const field of ['owner', 'revision', 'acceptance']) {
|
|
98
|
+
if (!['candidate', 'explicitly-unresolved'].includes(inspection[field].state)) findings.push({ issue: number, field, state: inspection[field].state });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const textLinks = [[records.outcome, records.increment], [records.outcome, records.next], [records.increment, records.task]].map(([parent, child]) => {
|
|
102
|
+
const parentMentionsChild = textReference(parent.body, child.number, repository);
|
|
103
|
+
const field = child.inspection.parent;
|
|
104
|
+
const referencesExpected = 'value' in field ? textReference(field.value, parent.number, repository).observed : null;
|
|
105
|
+
if (!parentMentionsChild.observed) findings.push({ issue: parent.number, field: 'child-text-reference', state: 'not-established', child: child.number });
|
|
106
|
+
if (referencesExpected !== true) findings.push({ issue: child.number, field: 'parent-text-reference', state: 'not-established', parent: parent.number });
|
|
107
|
+
return { parent: parent.number, child: child.number, parentMentionsChild, childParent: { ...field, referencesExpected } };
|
|
108
|
+
});
|
|
109
|
+
return { repository, projectSelection, capabilities: { project: 'not-probed', nativeLinks: 'not-probed' }, records, textLinks, findings };
|
|
110
|
+
}
|
|
111
|
+
function items(request, bound, projectId) {
|
|
112
|
+
const response = json(['api', 'graphql', '--input', '-'], JSON.stringify({ query: itemQuery, variables: { ids: [request.increment.itemId, request.task.itemId], agentFieldId: request.agentFieldId } }));
|
|
113
|
+
requireValue(response.data.agentField?.dataType === 'TEXT', 'The Agent field must be a text field.');
|
|
114
|
+
return response.data.nodes.map((item, index) => {
|
|
115
|
+
const record = index === 0 ? request.increment : request.task;
|
|
116
|
+
const parent = index === 0 ? request.outcome : request.increment.issue;
|
|
117
|
+
const expected = `https://github.com/${bound.repository}/issues/${record.issue}`;
|
|
118
|
+
requireValue(item && item.id === record.itemId && item.project.id === projectId && item.project.url === bound.projectUrl, 'Project item does not belong to the selected Project.');
|
|
119
|
+
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.');
|
|
120
|
+
requireValue(!item.fieldValues.pageInfo.hasNextPage, 'Project field response is incomplete; inspect the remaining fields before applying.');
|
|
121
|
+
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))) };
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function inspect(request, config) {
|
|
125
|
+
const bound = scope(request, config);
|
|
126
|
+
const actor = json(['api', 'user']).login;
|
|
127
|
+
const project = json(['project', 'view', bound.number, '--owner', bound.owner, '--format', 'json']);
|
|
128
|
+
requireValue(project.url === bound.projectUrl && !project.closed, 'Selected Project is unavailable or closed.');
|
|
129
|
+
const allFields = json(['project', 'field-list', bound.number, '--owner', bound.owner, '--format', 'json']).fields;
|
|
130
|
+
const status = allFields.find(field => field.id === request.statusFieldId);
|
|
131
|
+
const agent = allFields.find(field => field.id === request.agentFieldId);
|
|
132
|
+
const option = status?.options?.find(value => value.id === request.statusOptionId);
|
|
133
|
+
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.');
|
|
134
|
+
requireValue(option.name.replace(/[-_\s]/g, '').toLowerCase() === 'inprogress', 'A resume checkpoint requires the existing In progress status option.');
|
|
135
|
+
const records = { outcome: issue(request.outcome, bound), increment: issue(request.increment.issue, bound), task: issue(request.task.issue, bound) };
|
|
136
|
+
requireValue(Object.values(records).every(record => record.state === 'OPEN'), 'This resume operation requires open outcome, increment and task records.');
|
|
137
|
+
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) } };
|
|
138
|
+
}
|
|
139
|
+
function bodyFor(body, request, status, observed, reason) {
|
|
140
|
+
// shortcut: only the observed literal Next action line; extend when an adopter needs another brief format.
|
|
141
|
+
requireValue(/^Next action:[^\r\n]*/m.test(body), 'Task brief needs its current literal Next action line; no body changes were made.');
|
|
142
|
+
const first = body.indexOf(start);
|
|
143
|
+
const last = body.indexOf(end);
|
|
144
|
+
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.');
|
|
145
|
+
let preserved = first < 0 ? body : body.slice(0, first) + body.slice(last + end.length);
|
|
146
|
+
const next = status === 'synchronized' ? request.nextAction : `Resolve the pending Project checkpoint: ${reason}`;
|
|
147
|
+
preserved = preserved.replace(/^Next action:[^\r\n]*/m, () => `Next action: ${next}`);
|
|
148
|
+
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}`;
|
|
149
|
+
return `${preserved.trimEnd()}\n\n${start}\n## Current Project checkpoint\n\nResult: ${status}\n${rows}\n${end}\n`;
|
|
150
|
+
}
|
|
151
|
+
function apply(request, inspection, config) {
|
|
152
|
+
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.');
|
|
153
|
+
const current = inspect(request, config);
|
|
154
|
+
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 };
|
|
155
|
+
const bound = scope(request, config);
|
|
156
|
+
const projectId = current.context.project.id;
|
|
157
|
+
const originalBody = current.context.issues.task.body;
|
|
158
|
+
bodyFor(originalBody, request, 'pending', [], 'validation');
|
|
159
|
+
let observed = current.context.items;
|
|
160
|
+
let failure = '';
|
|
161
|
+
let operation = '';
|
|
162
|
+
try {
|
|
163
|
+
for (const record of [request.increment, request.task]) {
|
|
164
|
+
for (const [field, flag, value] of [[request.agentFieldId, '--text', record.agent], [request.statusFieldId, '--single-select-option-id', request.statusOptionId]]) {
|
|
165
|
+
operation = `Issue #${record.issue}, item ${record.itemId}, ${field === request.agentFieldId ? 'Agent' : 'Status'} field ${field}`;
|
|
166
|
+
const result = call(['project', 'item-edit', '--id', record.itemId, '--field-id', field, '--project-id', projectId, flag, value, '--format', 'json']);
|
|
167
|
+
if (result.failure) {
|
|
168
|
+
observed = items(request, bound, projectId);
|
|
169
|
+
const saved = observed.find(item => item.id === record.itemId).fields[field];
|
|
170
|
+
if ((flag === '--text' ? saved?.text : saved?.optionId) !== value) throw new Error(result.failure);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
operation = `Saved Status/Agent readback for issues #${request.increment.issue} and #${request.task.issue}`;
|
|
175
|
+
observed = items(request, bound, projectId);
|
|
176
|
+
for (const record of [request.increment, request.task]) {
|
|
177
|
+
const saved = observed.find(item => item.id === record.itemId).fields;
|
|
178
|
+
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.`);
|
|
179
|
+
}
|
|
180
|
+
} catch (error) { failure = `${operation}: ${error.message}`; }
|
|
181
|
+
const freshBody = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
|
|
182
|
+
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.' };
|
|
183
|
+
const status = failure ? 'pending' : 'synchronized';
|
|
184
|
+
const body = bodyFor(freshBody, request, status, observed, failure);
|
|
185
|
+
const written = call(['issue', 'edit', String(request.task.issue), '--repo', bound.repository, '--body-file', '-'], body);
|
|
186
|
+
const saved = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
|
|
187
|
+
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.' };
|
|
188
|
+
return { status, record: request.task.issue, lastObservedFields: observed, ...(failure ? { reason: failure, nextAction: 'Resolve the pending operation before claiming synchronization.' } : {}) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (process.argv[2] === '--help') {
|
|
192
|
+
console.log('Hierarchy: node github-project-checkpoint.mjs inspect-hierarchy REQUEST.json [CONFIG.json]\nInspect: node github-project-checkpoint.mjs inspect REQUEST.json [CONFIG.json]\nApply: node github-project-checkpoint.mjs apply REQUEST.json INSPECTION.json [CONFIG.json]');
|
|
193
|
+
} else {
|
|
194
|
+
try {
|
|
195
|
+
const [mode, requestPath, third, fourth] = process.argv.slice(2);
|
|
196
|
+
requireValue(['inspect', 'apply', 'inspect-hierarchy'].includes(mode), 'Expected inspect-hierarchy, inspect or apply; use --help for usage.');
|
|
197
|
+
const request = load(requestPath);
|
|
198
|
+
const config = load((mode === 'apply' ? fourth : third) || '.agents/planning.json');
|
|
199
|
+
const result = mode === 'inspect-hierarchy' ? inspectHierarchy(request, config) : mode === 'inspect' ? inspect(request, config) : apply(request, load(third), config);
|
|
200
|
+
console.log(JSON.stringify(result));
|
|
201
|
+
if (result.status && result.status !== 'synchronized') process.exitCode = 1;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.log(JSON.stringify(process.argv[2] === 'inspect-hierarchy'
|
|
204
|
+
? { status: 'inspection-unavailable', reason: error.message, nextAction: 'Read the affected configuration or record before claiming a completed inspection.' }
|
|
205
|
+
: { status: 'pending', reason: error.message, nextAction: 'Inspect the affected record and resolve this operation before claiming synchronization.' }));
|
|
206
|
+
process.exitCode = 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -156,7 +156,7 @@ Use this contribution lifecycle:
|
|
|
156
156
|
|
|
157
157
|
A starting battle plan exposes likely work, dependencies and parallel opportunities without precreating, assigning or reserving every future task. The increment owner keeps acceptance, sequence and integration clear and resolves overlap/dependencies. Explicit assignments and handoffs remain valid. Two agents sharing an account are distinct contributors. Checklists suffice for routine steps; do not create a task for every command. Task creation within assessed scope requires no fresh human approval.
|
|
158
158
|
|
|
159
|
-
**
|
|
159
|
+
**One contribution claim is a ceiling, not a retention requirement.** A blocked assignment still counts until its release is verified. For an unfinished task, when no useful authorized in-task step remains, hand over the remaining gap, remove your assignment and read back its removal; do not retain it merely because its dependency is unresolved. Preserve genuine assigned watcher duties; routine discussion reads alone do not justify retaining an exhausted reservation. Finish, explicitly hand over or release the current assignment before claiming another. Do not reserve queued tasks. Outcome or increment coordination ownership does not reserve contribution tasks. Agents sharing an account remain distinct through their recorded agent/session identities.
|
|
160
160
|
|
|
161
161
|
Before declaring a contribution ready to start, complete this claim receipt: **no other contribution assignment held; parent increment; current assessed plan revision; non-overlapping scope; dependencies (or explicitly none); actual account; distinct agent/session; saved claim and reciprocal-link readback; competing-claim recheck.** Record all fields on the task, even when some are already known in conversation. Assignment does not increase authority. When explaining a proposed claim, make these required fields and the before/after overlap checks explicit.
|
|
162
162
|
|
|
@@ -188,15 +188,31 @@ actual acknowledgement.
|
|
|
188
188
|
|
|
189
189
|
At each work checkpoint, reconcile tracking in this order:
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
191
|
+
When the affected records include an outcome, current Now, provisional Next and
|
|
192
|
+
a task, and `gh` works, MUST first use the [read-only hierarchy inspector](github-project-checkpoint.md#inspect-the-hierarchy).
|
|
193
|
+
Construct its request from those actual records and consume its findings before
|
|
194
|
+
claiming completeness; identical draft/remote text is not that comparison.
|
|
195
|
+
Do not manufacture a Next issue for this route. Its configuration report is one
|
|
196
|
+
source for the selection decision below, not proof of capability or acceptance.
|
|
197
|
+
|
|
198
|
+
1. **Establish selection before checking Project capability.** Reuse the
|
|
199
|
+
established backend. From configuration, project requirements or explicit
|
|
200
|
+
direction, choose the applicable branch:
|
|
201
|
+
- **Selected/required:** verify the repository, Project and account, then
|
|
202
|
+
inspect its saved setup and applicable operations. For join/resume of an
|
|
203
|
+
active open task with working `gh`, MUST use the [executable Project checkpoint](github-project-checkpoint.md).
|
|
204
|
+
Read its inspection and handle current discussion before applying it;
|
|
205
|
+
do not manually publish synchronization-success text on that route.
|
|
206
|
+
If an applicable operation is unavailable, retain its exact pending state
|
|
207
|
+
under the recovery rules below. An unavailable API or bootstrap default
|
|
208
|
+
does not prove a missing view, field or membership.
|
|
209
|
+
- **Established none:** continue issue-side reconciliation without Project
|
|
210
|
+
setup, fields or synchronization.
|
|
211
|
+
- **Unresolved:** inspect available configuration, requirements, direction and
|
|
212
|
+
issue records. Record the selection gap and keep Project work conditional;
|
|
213
|
+
there is no established mirror operation to report pending or unsynchronized.
|
|
214
|
+
Omitted information and API availability establish neither selection nor
|
|
215
|
+
an explicit choice to use no Project.
|
|
200
216
|
2. **Inspect each affected record.** Read each outcome, increment and task
|
|
201
217
|
against the minimum-information table above. Preserve its own owner/account,
|
|
202
218
|
applicable agent/session, revision and acceptance; mark missing required
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Inspect and reconcile GitHub records
|
|
2
|
+
|
|
3
|
+
Resolve `scripts/github-project-checkpoint.mjs` relative to this installed
|
|
4
|
+
planning skill, including when called from `contribute`. Use the existing
|
|
5
|
+
authenticated `gh` account and saved project configuration. The default is
|
|
6
|
+
`.agents/planning.json`; an existing project-declared configuration path may be
|
|
7
|
+
supplied last. These operations require no new grants, account changes or tokens.
|
|
8
|
+
Local tracking keeps its existing path.
|
|
9
|
+
|
|
10
|
+
## Inspect the hierarchy
|
|
11
|
+
|
|
12
|
+
When reconciling an outcome, current Now, an affected provisional Next and a
|
|
13
|
+
task together with working `gh`, planning and `contribute` MUST first use the
|
|
14
|
+
read-only hierarchy inspector. Supply those actual distinct issue numbers;
|
|
15
|
+
do not invent a Next issue to satisfy the command. Other record sets retain
|
|
16
|
+
their existing coordination path.
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"outcome": 1,
|
|
21
|
+
"increment": {"issue": 2},
|
|
22
|
+
"next": {"issue": 3},
|
|
23
|
+
"task": {"issue": 4}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs inspect-hierarchy request.json`.
|
|
28
|
+
This reads the four actual issues and their returned comments. It writes
|
|
29
|
+
nothing and makes no Project or native-link request. Read the raw records and
|
|
30
|
+
consume `findings` before claiming that the records are correct:
|
|
31
|
+
|
|
32
|
+
- `projectSelection` reports configuration facts. `not-recorded` means selection
|
|
33
|
+
is unresolved from that configuration, not an explicit choice to use no
|
|
34
|
+
Project. `recorded` does not prove capability; consider actual project
|
|
35
|
+
requirements and explicit direction too. Preserve invalid configuration.
|
|
36
|
+
- Each record's `inspection` reports literal owner, revision, acceptance and
|
|
37
|
+
Parent candidates with raw line evidence. `candidate` is text, not verified
|
|
38
|
+
ownership or passed acceptance. `explicitly-unresolved` preserves a recorded
|
|
39
|
+
unknown. Empty/missing literals, unsupported syntax and multiple candidates
|
|
40
|
+
remain unestablished by this inspector; they do not prove semantic information
|
|
41
|
+
is absent or that the issue is invalid. Inspect the actual prose/history
|
|
42
|
+
rather than forcing a new format or choosing a historical value.
|
|
43
|
+
- `textLinks` compares the three requested textual relationships. A mention is
|
|
44
|
+
observed text, not proof of a current native relationship. Inspect both sides
|
|
45
|
+
and repair known reciprocal gaps under the existing coordination rules.
|
|
46
|
+
- `capabilities` are `not-probed`; do not turn that into an outage or native-link
|
|
47
|
+
absence. `inspection-unavailable` means the read/report operation failed,
|
|
48
|
+
not that a Project mirror is pending.
|
|
49
|
+
|
|
50
|
+
A successful command produces a diagnostic report, never a correctness or
|
|
51
|
+
synchronization verdict. Identical drafts and saved bodies do not establish
|
|
52
|
+
completeness. Before claiming completeness, account for every finding through
|
|
53
|
+
actual semantic review, authorized repair with saved readback where needed, or
|
|
54
|
+
honest unresolved recording in the existing plan or record. Follow the selection
|
|
55
|
+
finding's conditional next action, which targets the existing outcome issue
|
|
56
|
+
for saving and reading back a still-unresolved gap. Reuse an already saved gap
|
|
57
|
+
without an unchanged write. Configuration alone may leave the choice unresolved
|
|
58
|
+
while requirements or explicit direction establish it. Unsupported
|
|
59
|
+
syntax does not force normalization or another artifact.
|
|
60
|
+
Apply the existing per-record rules to known gaps, marking missing
|
|
61
|
+
required information unresolved rather than inventing it; preserve completed
|
|
62
|
+
acceptance and attribution. Read back authorized changes and compare reciprocal
|
|
63
|
+
links before reporting reconciliation. Unsupported syntax needs actual review,
|
|
64
|
+
not automatic normalization. Semantic acceptance remains with the caller and
|
|
65
|
+
required independent review.
|
|
66
|
+
|
|
67
|
+
## Synchronize selected Project fields
|
|
68
|
+
|
|
69
|
+
For a join/resume checkpoint on an active open task with a selected GitHub Project and working `gh`,
|
|
70
|
+
planning and `contribute` MUST use the bundled
|
|
71
|
+
`scripts/github-project-checkpoint.mjs` operation. Do not manually publish a
|
|
72
|
+
Project synchronization-success claim on this route. The operation reads the
|
|
73
|
+
records/discussion, updates the two items, verifies their saved fields and only
|
|
74
|
+
then generates the task's synchronization result. It does not allocate owners,
|
|
75
|
+
approve a plan, acknowledge another person or complete the increment.
|
|
76
|
+
|
|
77
|
+
This mutation route applies to its open outcome, increment and active task.
|
|
78
|
+
GitHub Issues-only tracking uses no Project field mutations.
|
|
79
|
+
|
|
80
|
+
## Inspect, decide, apply
|
|
81
|
+
|
|
82
|
+
Create a request from the actual records and Project fields. IDs below are
|
|
83
|
+
placeholders. Preserve each record's own verified agent/session; do not copy the
|
|
84
|
+
task contributor onto the increment. The status option must be the existing
|
|
85
|
+
In progress equivalent, never Done.
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"outcome": 1,
|
|
90
|
+
"increment": {"issue": 2, "itemId": "INCREMENT_ITEM_ID", "agent": "verified increment owner/session"},
|
|
91
|
+
"task": {"issue": 4, "itemId": "TASK_ITEM_ID", "agent": "verified task owner/session"},
|
|
92
|
+
"statusFieldId": "STATUS_FIELD_ID",
|
|
93
|
+
"agentFieldId": "AGENT_FIELD_ID",
|
|
94
|
+
"statusOptionId": "IN_PROGRESS_OPTION_ID",
|
|
95
|
+
"nextAction": "the actual next step in this task"
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
1. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs inspect request.json`.
|
|
100
|
+
The default configuration is `.agents/planning.json` in the project. An
|
|
101
|
+
existing project-declared configuration path may be supplied as the final
|
|
102
|
+
argument. Save the returned JSON in a permitted working location.
|
|
103
|
+
2. Read that inspection, including the increment discussion, current revision,
|
|
104
|
+
dependencies and claims. Acknowledge relevant handoffs through the authorized
|
|
105
|
+
channel and resolve overlap or material changes under the existing rules.
|
|
106
|
+
The operation reading a comment does not mean its recipient acknowledged it.
|
|
107
|
+
If that work changes these records, inspect again before applying.
|
|
108
|
+
3. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs apply request.json inspection.json`.
|
|
109
|
+
Supply the same explicit configuration path last when one was used above.
|
|
110
|
+
The operation rechecks inspected context, updates only task/increment
|
|
111
|
+
Status/Agent, reads back the actual fields, then publishes generated task
|
|
112
|
+
progress and reads it back. It preserves Horizon and the rest of the brief.
|
|
113
|
+
The current brief needs a literal `Next action:` line; if absent, maintain
|
|
114
|
+
that field truthfully before inspecting again. Do not erase existing content.
|
|
115
|
+
|
|
116
|
+
Use direct commands with literal paths supported by the runtime permissions.
|
|
117
|
+
Do not infer unavailable authority from one rejected compound command when an
|
|
118
|
+
already-authorized direct invocation remains available.
|
|
119
|
+
|
|
120
|
+
## Read the result
|
|
121
|
+
|
|
122
|
+
- `synchronized`: the requested fields and generated task update were read back.
|
|
123
|
+
This establishes this checkpoint only, not task or increment completion.
|
|
124
|
+
- `changed-context`: inspect the returned facts and resolve the change. A body
|
|
125
|
+
changed before publication stays untouched, even if fields already changed.
|
|
126
|
+
- `pending`: keep the precise affected operation and next action visible. A
|
|
127
|
+
partial or ambiguous field result cannot generate synchronization success.
|
|
128
|
+
The operation reads saved state before resolving uncertain writes and never
|
|
129
|
+
automatically repeats a mutation. If the pending note itself could not be
|
|
130
|
+
saved, its output does not prove the issue was updated.
|
|
131
|
+
|
|
132
|
+
Do not treat an exit code, assignment or local note as a saved-field result.
|
|
133
|
+
Keep useful authorized issue work moving while Project work is pending. If
|
|
134
|
+
`gh` or this operation is unavailable, use other already-authorized capabilities
|
|
135
|
+
under the coordination protocol, preserving the same truthful claim dependency.
|
|
136
|
+
Do not request broader access while an existing authorized route works, or
|
|
137
|
+
claim the executable operation enforced an untested browser/manual path.
|
|
@@ -0,0 +1,208 @@
|
|
|
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 repositoryFor(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' && /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[\w.-]+$/.test(repository) && !['.', '..'].includes(repository.split('/')[1]), 'Invalid configured GitHub repository.');
|
|
33
|
+
return repository;
|
|
34
|
+
}
|
|
35
|
+
function scope(request, config) {
|
|
36
|
+
const repository = repositoryFor(config);
|
|
37
|
+
const url = new URL(config.github?.projectUrl);
|
|
38
|
+
const match = url.pathname.match(/^\/(?:users|orgs)\/([\w.-]+)\/projects\/([1-9]\d*)(?:\/views\/[1-9]\d*)?\/?$/);
|
|
39
|
+
requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && match && !url.username && !url.password, 'A selected GitHub Project URL is required.');
|
|
40
|
+
requireValue([request.outcome, request.increment?.issue, request.task?.issue].every(n => Number.isSafeInteger(n) && n > 0), 'Outcome, increment and task issue numbers are required.');
|
|
41
|
+
requireValue(new Set([request.outcome, request.increment.issue, request.task.issue]).size === 3, 'The three issue roles must be distinct.');
|
|
42
|
+
for (const value of [request.increment.itemId, request.task.itemId, request.statusFieldId, request.agentFieldId, request.statusOptionId]) {
|
|
43
|
+
requireValue(typeof value === 'string' && value.length > 0 && !/[\r\n\0]/.test(value), 'Existing item, field and option IDs are required.');
|
|
44
|
+
}
|
|
45
|
+
requireValue(request.increment.itemId !== request.task.itemId && request.statusFieldId !== request.agentFieldId, 'Item and field roles must be distinct.');
|
|
46
|
+
return { repository, owner: match[1], number: match[2], projectUrl: `https://github.com${url.pathname.split('/views/')[0].replace(/\/$/, '')}` };
|
|
47
|
+
}
|
|
48
|
+
function issue(number, bound) {
|
|
49
|
+
return json(['issue', 'view', String(number), '--repo', bound.repository, '--json', 'number,url,body,state,assignees,labels,comments']);
|
|
50
|
+
}
|
|
51
|
+
function briefField(body, pattern, cue) {
|
|
52
|
+
// shortcut: observed literal brief fields only; extend when an actual brief needs another syntax.
|
|
53
|
+
const lines = body.split(/\r?\n/).map((text, index) => ({ line: index + 1, text }));
|
|
54
|
+
const matches = lines.flatMap(line => {
|
|
55
|
+
const match = line.text.match(pattern);
|
|
56
|
+
return match ? [{ ...line, value: match[1].trim() }] : [];
|
|
57
|
+
});
|
|
58
|
+
const literalEvidence = matches.map(({ line, text }) => ({ line, text }));
|
|
59
|
+
if (matches.length > 1) return { state: 'multiple-candidates', evidence: literalEvidence };
|
|
60
|
+
if (matches.length === 1) {
|
|
61
|
+
const { value } = matches[0];
|
|
62
|
+
return { state: !value ? 'empty' : /^(?:unresolved|unknown|tbd|unclaimed)(?:\b|$)/i.test(value) ? 'explicitly-unresolved' : 'candidate', value, evidence: literalEvidence };
|
|
63
|
+
}
|
|
64
|
+
const evidence = lines.filter(line => cue.test(line.text));
|
|
65
|
+
return { state: evidence.length ? 'unsupported-format' : 'literal-field-missing', evidence };
|
|
66
|
+
}
|
|
67
|
+
function textReference(body, number, repository) {
|
|
68
|
+
const escaped = repository.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
69
|
+
const reference = new RegExp(`(?:(?<![\\w./#:-])#${number}\\b|https://github\\.com/${escaped}/issues/${number}(?![\\w/]))`, 'i');
|
|
70
|
+
const evidence = body.split(/\r?\n/).flatMap((text, index) => reference.test(text) ? [{ line: index + 1, text }] : []);
|
|
71
|
+
return { observed: evidence.length > 0, evidence };
|
|
72
|
+
}
|
|
73
|
+
function inspectHierarchy(request, config) {
|
|
74
|
+
const repository = repositoryFor(config);
|
|
75
|
+
const roles = { outcome: request?.outcome, increment: request?.increment?.issue, next: request?.next?.issue, task: request?.task?.issue };
|
|
76
|
+
requireValue(Object.values(roles).every(n => Number.isSafeInteger(n) && n > 0) && new Set(Object.values(roles)).size === 4, 'Four distinct outcome, increment, next and task issue numbers are required.');
|
|
77
|
+
const projectSelection = { state: 'not-recorded', source: 'configuration' };
|
|
78
|
+
if (Object.hasOwn(config.github, 'projectUrl')) {
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(config.github.projectUrl);
|
|
81
|
+
requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && !url.username && !url.password && !url.search && !url.hash && /^\/(?:users|orgs)\/[\w.-]+\/projects\/[1-9]\d*(?:\/views\/[1-9]\d*)?\/?$/.test(url.pathname), 'Invalid recorded Project URL.');
|
|
82
|
+
Object.assign(projectSelection, { state: 'recorded', url: `https://github.com${url.pathname.split('/views/')[0].replace(/\/$/, '')}` });
|
|
83
|
+
} catch { projectSelection.state = 'invalid'; }
|
|
84
|
+
}
|
|
85
|
+
const records = {};
|
|
86
|
+
const findings = projectSelection.state === 'recorded' ? [] : [{ issue: roles.outcome, field: 'projectSelection', state: projectSelection.state, nextAction: `Establish selection from applicable project requirements and explicit direction. If it remains unresolved, save that selection gap and conditional Project work on outcome #${roles.outcome} and read back its saved body before claiming reconciliation complete. Reuse an already saved gap without an unchanged write.` }];
|
|
87
|
+
for (const [role, number] of Object.entries(roles)) {
|
|
88
|
+
const record = issue(number, { repository });
|
|
89
|
+
requireValue(record.number === number && record.url?.toLowerCase() === `https://github.com/${repository}/issues/${number}`.toLowerCase() && typeof record.body === 'string', 'Returned issue does not match its requested repository/number or brief.');
|
|
90
|
+
const inspection = {
|
|
91
|
+
owner: briefField(record.body, /^\s*(?:(?:Outcome|Increment|Task) )?Owner:\s*(.*)$/i, /\bowner\b/i),
|
|
92
|
+
revision: briefField(record.body, /^\s*Revision:\s*(.*)$/i, /\brevision\b/i),
|
|
93
|
+
acceptance: briefField(record.body, /^\s*(?:Tentative )?Acceptance(?: criteria| evidence)?:\s*(.*)$/i, /\bacceptance\b/i),
|
|
94
|
+
parent: briefField(record.body, /^\s*Parent(?: outcome| increment)?:\s*(.*)$/i, /\bparent\b/i)
|
|
95
|
+
};
|
|
96
|
+
records[role] = { ...record, inspection };
|
|
97
|
+
for (const field of ['owner', 'revision', 'acceptance']) {
|
|
98
|
+
if (!['candidate', 'explicitly-unresolved'].includes(inspection[field].state)) findings.push({ issue: number, field, state: inspection[field].state });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const textLinks = [[records.outcome, records.increment], [records.outcome, records.next], [records.increment, records.task]].map(([parent, child]) => {
|
|
102
|
+
const parentMentionsChild = textReference(parent.body, child.number, repository);
|
|
103
|
+
const field = child.inspection.parent;
|
|
104
|
+
const referencesExpected = 'value' in field ? textReference(field.value, parent.number, repository).observed : null;
|
|
105
|
+
if (!parentMentionsChild.observed) findings.push({ issue: parent.number, field: 'child-text-reference', state: 'not-established', child: child.number });
|
|
106
|
+
if (referencesExpected !== true) findings.push({ issue: child.number, field: 'parent-text-reference', state: 'not-established', parent: parent.number });
|
|
107
|
+
return { parent: parent.number, child: child.number, parentMentionsChild, childParent: { ...field, referencesExpected } };
|
|
108
|
+
});
|
|
109
|
+
return { repository, projectSelection, capabilities: { project: 'not-probed', nativeLinks: 'not-probed' }, records, textLinks, findings };
|
|
110
|
+
}
|
|
111
|
+
function items(request, bound, projectId) {
|
|
112
|
+
const response = json(['api', 'graphql', '--input', '-'], JSON.stringify({ query: itemQuery, variables: { ids: [request.increment.itemId, request.task.itemId], agentFieldId: request.agentFieldId } }));
|
|
113
|
+
requireValue(response.data.agentField?.dataType === 'TEXT', 'The Agent field must be a text field.');
|
|
114
|
+
return response.data.nodes.map((item, index) => {
|
|
115
|
+
const record = index === 0 ? request.increment : request.task;
|
|
116
|
+
const parent = index === 0 ? request.outcome : request.increment.issue;
|
|
117
|
+
const expected = `https://github.com/${bound.repository}/issues/${record.issue}`;
|
|
118
|
+
requireValue(item && item.id === record.itemId && item.project.id === projectId && item.project.url === bound.projectUrl, 'Project item does not belong to the selected Project.');
|
|
119
|
+
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.');
|
|
120
|
+
requireValue(!item.fieldValues.pageInfo.hasNextPage, 'Project field response is incomplete; inspect the remaining fields before applying.');
|
|
121
|
+
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))) };
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function inspect(request, config) {
|
|
125
|
+
const bound = scope(request, config);
|
|
126
|
+
const actor = json(['api', 'user']).login;
|
|
127
|
+
const project = json(['project', 'view', bound.number, '--owner', bound.owner, '--format', 'json']);
|
|
128
|
+
requireValue(project.url === bound.projectUrl && !project.closed, 'Selected Project is unavailable or closed.');
|
|
129
|
+
const allFields = json(['project', 'field-list', bound.number, '--owner', bound.owner, '--format', 'json']).fields;
|
|
130
|
+
const status = allFields.find(field => field.id === request.statusFieldId);
|
|
131
|
+
const agent = allFields.find(field => field.id === request.agentFieldId);
|
|
132
|
+
const option = status?.options?.find(value => value.id === request.statusOptionId);
|
|
133
|
+
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.');
|
|
134
|
+
requireValue(option.name.replace(/[-_\s]/g, '').toLowerCase() === 'inprogress', 'A resume checkpoint requires the existing In progress status option.');
|
|
135
|
+
const records = { outcome: issue(request.outcome, bound), increment: issue(request.increment.issue, bound), task: issue(request.task.issue, bound) };
|
|
136
|
+
requireValue(Object.values(records).every(record => record.state === 'OPEN'), 'This resume operation requires open outcome, increment and task records.');
|
|
137
|
+
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) } };
|
|
138
|
+
}
|
|
139
|
+
function bodyFor(body, request, status, observed, reason) {
|
|
140
|
+
// shortcut: only the observed literal Next action line; extend when an adopter needs another brief format.
|
|
141
|
+
requireValue(/^Next action:[^\r\n]*/m.test(body), 'Task brief needs its current literal Next action line; no body changes were made.');
|
|
142
|
+
const first = body.indexOf(start);
|
|
143
|
+
const last = body.indexOf(end);
|
|
144
|
+
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.');
|
|
145
|
+
let preserved = first < 0 ? body : body.slice(0, first) + body.slice(last + end.length);
|
|
146
|
+
const next = status === 'synchronized' ? request.nextAction : `Resolve the pending Project checkpoint: ${reason}`;
|
|
147
|
+
preserved = preserved.replace(/^Next action:[^\r\n]*/m, () => `Next action: ${next}`);
|
|
148
|
+
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}`;
|
|
149
|
+
return `${preserved.trimEnd()}\n\n${start}\n## Current Project checkpoint\n\nResult: ${status}\n${rows}\n${end}\n`;
|
|
150
|
+
}
|
|
151
|
+
function apply(request, inspection, config) {
|
|
152
|
+
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.');
|
|
153
|
+
const current = inspect(request, config);
|
|
154
|
+
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 };
|
|
155
|
+
const bound = scope(request, config);
|
|
156
|
+
const projectId = current.context.project.id;
|
|
157
|
+
const originalBody = current.context.issues.task.body;
|
|
158
|
+
bodyFor(originalBody, request, 'pending', [], 'validation');
|
|
159
|
+
let observed = current.context.items;
|
|
160
|
+
let failure = '';
|
|
161
|
+
let operation = '';
|
|
162
|
+
try {
|
|
163
|
+
for (const record of [request.increment, request.task]) {
|
|
164
|
+
for (const [field, flag, value] of [[request.agentFieldId, '--text', record.agent], [request.statusFieldId, '--single-select-option-id', request.statusOptionId]]) {
|
|
165
|
+
operation = `Issue #${record.issue}, item ${record.itemId}, ${field === request.agentFieldId ? 'Agent' : 'Status'} field ${field}`;
|
|
166
|
+
const result = call(['project', 'item-edit', '--id', record.itemId, '--field-id', field, '--project-id', projectId, flag, value, '--format', 'json']);
|
|
167
|
+
if (result.failure) {
|
|
168
|
+
observed = items(request, bound, projectId);
|
|
169
|
+
const saved = observed.find(item => item.id === record.itemId).fields[field];
|
|
170
|
+
if ((flag === '--text' ? saved?.text : saved?.optionId) !== value) throw new Error(result.failure);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
operation = `Saved Status/Agent readback for issues #${request.increment.issue} and #${request.task.issue}`;
|
|
175
|
+
observed = items(request, bound, projectId);
|
|
176
|
+
for (const record of [request.increment, request.task]) {
|
|
177
|
+
const saved = observed.find(item => item.id === record.itemId).fields;
|
|
178
|
+
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.`);
|
|
179
|
+
}
|
|
180
|
+
} catch (error) { failure = `${operation}: ${error.message}`; }
|
|
181
|
+
const freshBody = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
|
|
182
|
+
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.' };
|
|
183
|
+
const status = failure ? 'pending' : 'synchronized';
|
|
184
|
+
const body = bodyFor(freshBody, request, status, observed, failure);
|
|
185
|
+
const written = call(['issue', 'edit', String(request.task.issue), '--repo', bound.repository, '--body-file', '-'], body);
|
|
186
|
+
const saved = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
|
|
187
|
+
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.' };
|
|
188
|
+
return { status, record: request.task.issue, lastObservedFields: observed, ...(failure ? { reason: failure, nextAction: 'Resolve the pending operation before claiming synchronization.' } : {}) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (process.argv[2] === '--help') {
|
|
192
|
+
console.log('Hierarchy: node github-project-checkpoint.mjs inspect-hierarchy REQUEST.json [CONFIG.json]\nInspect: node github-project-checkpoint.mjs inspect REQUEST.json [CONFIG.json]\nApply: node github-project-checkpoint.mjs apply REQUEST.json INSPECTION.json [CONFIG.json]');
|
|
193
|
+
} else {
|
|
194
|
+
try {
|
|
195
|
+
const [mode, requestPath, third, fourth] = process.argv.slice(2);
|
|
196
|
+
requireValue(['inspect', 'apply', 'inspect-hierarchy'].includes(mode), 'Expected inspect-hierarchy, inspect or apply; use --help for usage.');
|
|
197
|
+
const request = load(requestPath);
|
|
198
|
+
const config = load((mode === 'apply' ? fourth : third) || '.agents/planning.json');
|
|
199
|
+
const result = mode === 'inspect-hierarchy' ? inspectHierarchy(request, config) : mode === 'inspect' ? inspect(request, config) : apply(request, load(third), config);
|
|
200
|
+
console.log(JSON.stringify(result));
|
|
201
|
+
if (result.status && result.status !== 'synchronized') process.exitCode = 1;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.log(JSON.stringify(process.argv[2] === 'inspect-hierarchy'
|
|
204
|
+
? { status: 'inspection-unavailable', reason: error.message, nextAction: 'Read the affected configuration or record before claiming a completed inspection.' }
|
|
205
|
+
: { status: 'pending', reason: error.message, nextAction: 'Inspect the affected record and resolve this operation before claiming synchronization.' }));
|
|
206
|
+
process.exitCode = 1;
|
|
207
|
+
}
|
|
208
|
+
}
|