copilot-operator 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +556 -0
- package/dist/src/api/app.module.js +18 -0
- package/dist/src/api/dataAcl.js +109 -0
- package/dist/src/api/folderPicker.js +110 -0
- package/dist/src/api/main.js +132 -0
- package/dist/src/api/operator.controller.js +975 -0
- package/dist/src/api/operator.service.js +1971 -0
- package/dist/src/api/saveToDesktop.js +157 -0
- package/dist/src/api/security.js +225 -0
- package/dist/src/api/settings.js +57 -0
- package/dist/src/cli.js +531 -0
- package/dist/src/config/layout.js +66 -0
- package/dist/src/config/lockedPolicy.js +176 -0
- package/dist/src/config/schema.js +487 -0
- package/dist/src/context/contextFiles.js +295 -0
- package/dist/src/context/desktopMirror.js +113 -0
- package/dist/src/context/projectMirror.js +364 -0
- package/dist/src/exec/authorizer.js +61 -0
- package/dist/src/exec/checks.js +285 -0
- package/dist/src/exec/confinement.js +224 -0
- package/dist/src/exec/dangerous.js +161 -0
- package/dist/src/exec/environment.js +80 -0
- package/dist/src/exec/isolation.js +166 -0
- package/dist/src/exec/network.js +242 -0
- package/dist/src/exec/policy.js +201 -0
- package/dist/src/exec/policyManifest.js +84 -0
- package/dist/src/exec/processes.js +329 -0
- package/dist/src/exec/programs.js +290 -0
- package/dist/src/exec/redaction.js +93 -0
- package/dist/src/exec/reportFile.js +154 -0
- package/dist/src/exec/runner.js +245 -0
- package/dist/src/exec/scriptFiles.js +81 -0
- package/dist/src/exec/shellExecuteTrap.js +221 -0
- package/dist/src/exec/shells.js +298 -0
- package/dist/src/exec/stepEnv.js +72 -0
- package/dist/src/exec/winps.js +17 -0
- package/dist/src/exec/workDir.js +94 -0
- package/dist/src/log/runLog.js +43 -0
- package/dist/src/orchestrator/derivedChecks.js +95 -0
- package/dist/src/orchestrator/review.js +590 -0
- package/dist/src/orchestrator/taskRunner.js +1582 -0
- package/dist/src/plan/brief.js +1785 -0
- package/dist/src/plan/importPlan.js +202 -0
- package/dist/src/plan/schema.js +483 -0
- package/dist/src/plan/script.js +1012 -0
- package/dist/src/plan/systemGuide.js +1851 -0
- package/dist/src/protocol/parser.js +224 -0
- package/dist/src/protocol/replySchema.js +207 -0
- package/dist/src/protocol/reporter.js +75 -0
- package/dist/src/protocol/reviewSchema.js +205 -0
- package/dist/src/session/compose.js +67 -0
- package/dist/src/session/debugExport.js +165 -0
- package/dist/src/session/events.js +34 -0
- package/dist/src/session/exportRecord.js +261 -0
- package/dist/src/session/exports.js +484 -0
- package/dist/src/session/model.js +22 -0
- package/dist/src/session/retention.js +34 -0
- package/dist/src/session/runName.js +36 -0
- package/dist/src/session/store.js +440 -0
- package/dist/src/session/story.js +198 -0
- package/dist/src/transport/acceptance.js +34 -0
- package/dist/src/transport/chatSession.js +73 -0
- package/dist/src/transport/copilotTransport.js +1208 -0
- package/dist/src/transport/edgeCrash.js +59 -0
- package/dist/src/transport/locators.js +261 -0
- package/dist/src/transport/profileLock.js +102 -0
- package/dist/src/util/pacing.js +89 -0
- package/dist/src/vcs/commitHygiene.js +102 -0
- package/dist/src/vcs/git.js +286 -0
- package/dist/src/vcs/taskVcs.js +343 -0
- package/dist/web/404.html +15 -0
- package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
- package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
- package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
- package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
- package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
- package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
- package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
- package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
- package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
- package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
- package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
- package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
- package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
- package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
- package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
- package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
- package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
- package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
- package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
- package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
- package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
- package/dist/web/appearance.html +15 -0
- package/dist/web/appearance.txt +26 -0
- package/dist/web/defaults.html +15 -0
- package/dist/web/defaults.txt +26 -0
- package/dist/web/history.html +15 -0
- package/dist/web/history.txt +26 -0
- package/dist/web/import.html +15 -0
- package/dist/web/import.txt +26 -0
- package/dist/web/index.html +15 -0
- package/dist/web/index.txt +26 -0
- package/dist/web/level1.html +15 -0
- package/dist/web/level1.txt +26 -0
- package/dist/web/presets.html +18 -0
- package/dist/web/presets.txt +26 -0
- package/dist/web/sessions/view.html +15 -0
- package/dist/web/sessions/view.txt +26 -0
- package/dist/web/system.html +15 -0
- package/dist/web/system.txt +26 -0
- package/package.json +111 -0
- package/prompts/level1.md +535 -0
- package/prompts/organisation.bg.md +44 -0
- package/prompts/organisation.en.md +44 -0
- package/prompts/persona.bg.md +17 -0
- package/prompts/persona.en.md +17 -0
- package/prompts/review1.md +277 -0
- package/prompts/work.bg.md +19 -0
- package/prompts/work.en.md +19 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { DEFAULT_MIRROR, DEFAULT_VCS } from '../session/store.js';
|
|
2
|
+
import { availableShells, detectShells } from '../exec/shells.js';
|
|
3
|
+
/** The heading under which a task's acceptance bar reaches Copilot. English, like level 1. */
|
|
4
|
+
const EXPECTED_HEADER = '### Expected result';
|
|
5
|
+
/** The heading the session's goal gets when it opens the level-2 instructions. */
|
|
6
|
+
const GOAL_HEADER = '## Goal of this session';
|
|
7
|
+
/**
|
|
8
|
+
* The heading the operator's persona gets when it opens a task's level 2.
|
|
9
|
+
*
|
|
10
|
+
* A way of working, not a character, so it is headed by what it is for rather than by a name —
|
|
11
|
+
* the operator's persona is nameless on purpose, so that it can be swapped for another when a
|
|
12
|
+
* different kind of work needs a different approach.
|
|
13
|
+
*/
|
|
14
|
+
export const PERSONA_HEADER = '## How to carry out this work';
|
|
15
|
+
/**
|
|
16
|
+
* The line that closes the persona, written out rather than inferred.
|
|
17
|
+
*
|
|
18
|
+
* The persona is free text the operator wrote, and it may carry headings and rules of its own, so
|
|
19
|
+
* "up to the next heading" would cut it wherever it happened to use one. The plan export has to lift
|
|
20
|
+
* it back out exactly — an exported plan imported again must get the persona that is in the field at
|
|
21
|
+
* that moment, not the old one and the new one stacked — and an explicit end is the only thing that
|
|
22
|
+
* makes that exact. It reads as a plain aside to the model that sees it.
|
|
23
|
+
*/
|
|
24
|
+
export const PERSONA_END = '(end of how to carry out this work)';
|
|
25
|
+
/** The persona as it opens a task's level 2, or nothing when there is none. */
|
|
26
|
+
export function personaBlock(persona) {
|
|
27
|
+
const body = persona.trim();
|
|
28
|
+
return body ? `${PERSONA_HEADER}\n\n${body}\n\n${PERSONA_END}` : '';
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A level 2 with any persona block at its head taken off.
|
|
32
|
+
*
|
|
33
|
+
* Used both ways round: by the plan export, so the file it writes is the plan without the approach
|
|
34
|
+
* that happened to be in force, and by `composeLevel2`, so a plan that already carries a persona —
|
|
35
|
+
* hand-written, or exported from somewhere that did not strip it — gets the current one instead of a
|
|
36
|
+
* second one.
|
|
37
|
+
*/
|
|
38
|
+
export function withoutPersona(level2) {
|
|
39
|
+
const text = level2.trimStart();
|
|
40
|
+
if (!text.startsWith(PERSONA_HEADER))
|
|
41
|
+
return level2;
|
|
42
|
+
const end = text.indexOf(PERSONA_END);
|
|
43
|
+
if (end < 0)
|
|
44
|
+
return level2;
|
|
45
|
+
return text.slice(end + PERSONA_END.length).replace(/^\s+/, '');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The text of one task as Copilot will receive it.
|
|
49
|
+
*
|
|
50
|
+
* The prompt and the acceptance bar are joined here rather than in the schema so that the
|
|
51
|
+
* stored task is exactly what was sent: a task that can be re-read a month later and matched
|
|
52
|
+
* against its summary, with no second document to consult.
|
|
53
|
+
*/
|
|
54
|
+
export function composePrompt(task) {
|
|
55
|
+
const prompt = task.prompt.trim();
|
|
56
|
+
const expected = task.expected.trim();
|
|
57
|
+
if (!expected)
|
|
58
|
+
return prompt;
|
|
59
|
+
return `${prompt}\n\n${EXPECTED_HEADER}\n\n${expected}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Level 2 for one task: the operator's persona first, then its own instructions if it has any,
|
|
63
|
+
* otherwise the session's, goal first.
|
|
64
|
+
*
|
|
65
|
+
* The persona goes on every task, whichever branch the rest takes, because it is how *all* of this
|
|
66
|
+
* work is carried out — a task with instructions of its own replaces the session's instructions,
|
|
67
|
+
* not the approach. It is written in here rather than sent separately so that it travels with every
|
|
68
|
+
* task: level 2 is repeated with each task in the conversation, and a long conversation loses its
|
|
69
|
+
* early turns, which is where a once-sent persona would have been. It sits inside level 2 and so
|
|
70
|
+
* under level 1, which it cannot change — the persona is the operator's approach, not a way round
|
|
71
|
+
* the runner's contract.
|
|
72
|
+
*/
|
|
73
|
+
export function composeLevel2(session, task, persona = '') {
|
|
74
|
+
const rest = level2Without(session, task);
|
|
75
|
+
const block = personaBlock(persona);
|
|
76
|
+
if (!block)
|
|
77
|
+
return rest;
|
|
78
|
+
return rest ? `${block}\n\n${rest}` : block;
|
|
79
|
+
}
|
|
80
|
+
/** The task's own level 2 if it has any, otherwise the session's, goal first — persona excluded. */
|
|
81
|
+
function level2Without(session, task) {
|
|
82
|
+
const own = withoutPersona(task.level2).trim();
|
|
83
|
+
if (own)
|
|
84
|
+
return own;
|
|
85
|
+
const shared = withoutPersona(session.level2).trim();
|
|
86
|
+
const goal = session.goal.trim();
|
|
87
|
+
if (!goal)
|
|
88
|
+
return shared;
|
|
89
|
+
return shared ? `${GOAL_HEADER}\n\n${goal}\n\n${shared}` : `${GOAL_HEADER}\n\n${goal}`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* What makes two tasks the same task, for the purpose of spotting a plan imported twice.
|
|
93
|
+
*
|
|
94
|
+
* The title and the text that would actually be sent. Not the level 2, and not the branch or
|
|
95
|
+
* the commit message: those can be edited on the session afterwards, and a plan re-pasted
|
|
96
|
+
* with a better commit subject is still the same plan being imported a second time.
|
|
97
|
+
*/
|
|
98
|
+
export function taskSignature(task) {
|
|
99
|
+
return `${task.title.trim()}\u0000${task.prompt.trim()}`;
|
|
100
|
+
}
|
|
101
|
+
/** The same, for a whole session as the plan describes it. */
|
|
102
|
+
export function plannedSessionSignature(session) {
|
|
103
|
+
return session.tasks.map((t) => taskSignature({ title: t.title, prompt: composePrompt(t) })).join('\u0001');
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Checks that name a shell this machine has not got.
|
|
107
|
+
*
|
|
108
|
+
* A warning rather than a refusal, which is the line this importer already draws: a plan is
|
|
109
|
+
* refused for what is wrong with the document and warned about for what is wrong with running
|
|
110
|
+
* it here. The document is fine — `shell: "pwsh"` is a legal thing for a plan to ask for — and
|
|
111
|
+
* the same plan is correct on the machine it was written for, so refusing it would be refusing
|
|
112
|
+
* the wrong thing. But a check that asks for an interpreter this machine cannot start will end
|
|
113
|
+
* its task on a configuration error rather than on the work, and the moment to hear that is now,
|
|
114
|
+
* while the queue is still sitting in front of somebody, and not four tasks into a run.
|
|
115
|
+
*
|
|
116
|
+
* One line per missing shell, not one per check: the fix is the same for all of them.
|
|
117
|
+
*/
|
|
118
|
+
function unavailableShellWarnings(plan) {
|
|
119
|
+
const inventory = detectShells();
|
|
120
|
+
const here = availableShells(inventory);
|
|
121
|
+
const asked = new Map();
|
|
122
|
+
for (const session of plan.sessions) {
|
|
123
|
+
for (const task of session.tasks) {
|
|
124
|
+
for (const check of task.checks) {
|
|
125
|
+
if (!check.shell || inventory.found[check.shell])
|
|
126
|
+
continue;
|
|
127
|
+
asked.set(check.shell, [...(asked.get(check.shell) ?? []), `"${check.name}" in "${task.title}"`]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return [...asked].map(([shell, checks]) => {
|
|
132
|
+
const named = checks.slice(0, 3).join(', ');
|
|
133
|
+
return (`${checks.length} check(s) ask to run in ${shell}, which is not installed on this machine: ${named}` +
|
|
134
|
+
`${checks.length > 3 ? `, and ${checks.length - 3} more` : ''}. ` +
|
|
135
|
+
`They will end their task with a configuration error rather than run. Install ${shell}, or edit those checks to ` +
|
|
136
|
+
`use one of the shells that are here${here.length > 0 ? ` (${here.join(', ')})` : ''}.`);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Creates everything the plan describes.
|
|
141
|
+
*
|
|
142
|
+
* `defaultModel` is what a session starts on when the plan does not name one, which keeps an
|
|
143
|
+
* imported session behaving like one made by hand in the UI.
|
|
144
|
+
*/
|
|
145
|
+
export async function importPlan(store, plan, defaultModel = '', defaultReviewModel = '',
|
|
146
|
+
/** The operator's persona at the moment of import, written into every task's level 2. */
|
|
147
|
+
persona = '') {
|
|
148
|
+
const sessions = [];
|
|
149
|
+
const warnings = [...unavailableShellWarnings(plan)];
|
|
150
|
+
/*
|
|
151
|
+
* One conversation for the whole plan, when it asked for that.
|
|
152
|
+
*
|
|
153
|
+
* The group is named after the plan, or after the moment of import when the plan has no name,
|
|
154
|
+
* because the name is what a session in the UI is shown and typed against later. A session
|
|
155
|
+
* that named its own group keeps it: a plan may want most of its sessions together and one
|
|
156
|
+
* of them apart.
|
|
157
|
+
*/
|
|
158
|
+
const sharedGroup = plan.conversation === 'shared'
|
|
159
|
+
? (plan.plan.trim() || `plan-${new Date().toISOString().slice(0, 16).replace(/[:T-]/g, '')}`).slice(0, 60)
|
|
160
|
+
: '';
|
|
161
|
+
for (const planned of plan.sessions) {
|
|
162
|
+
const mirror = { ...DEFAULT_MIRROR, ...(planned.mirror ?? {}) };
|
|
163
|
+
if (mirror.enabled && !mirror.rootDir.trim()) {
|
|
164
|
+
mirror.enabled = false;
|
|
165
|
+
warnings.push(`Session "${planned.name}" asked for project files without a root folder, so file attachment was left off.`);
|
|
166
|
+
}
|
|
167
|
+
const created = await store.createSession(planned.name, mirror);
|
|
168
|
+
const vcs = { ...DEFAULT_VCS, ...(planned.vcs ?? {}) };
|
|
169
|
+
vcs.branchPrefix = vcs.branchPrefix.trim() || DEFAULT_VCS.branchPrefix;
|
|
170
|
+
vcs.repoDir = vcs.repoDir.trim();
|
|
171
|
+
const group = planned.conversationGroup.trim() || sharedGroup;
|
|
172
|
+
await store.updateSession(created.id, (s) => {
|
|
173
|
+
s.onFailure = planned.onFailure;
|
|
174
|
+
// Absent means reviewed, which is the default everywhere else too.
|
|
175
|
+
// The reviewing model follows the same rule as the working one: the plan's word, else
|
|
176
|
+
// the standing default, else the session's own.
|
|
177
|
+
s.review = { enabled: planned.review?.enabled !== false, model: (planned.review?.model || defaultReviewModel).trim() };
|
|
178
|
+
s.conversationGroup = group || undefined;
|
|
179
|
+
s.model = (planned.model || defaultModel).trim() || undefined;
|
|
180
|
+
s.planName = plan.plan.trim() || undefined;
|
|
181
|
+
s.vcs = vcs;
|
|
182
|
+
});
|
|
183
|
+
const titles = [];
|
|
184
|
+
for (const task of planned.tasks) {
|
|
185
|
+
const added = await store.addTask(created.id, {
|
|
186
|
+
title: task.title,
|
|
187
|
+
level2: composeLevel2(planned, task, persona),
|
|
188
|
+
prompt: composePrompt(task),
|
|
189
|
+
// Kept even when version control is off for this session: the operator may turn it on
|
|
190
|
+
// afterwards, and throwing away a name the plan chose would make that a worse session
|
|
191
|
+
// than the same one imported a minute later.
|
|
192
|
+
vcsPlan: task.vcs,
|
|
193
|
+
checks: task.checks,
|
|
194
|
+
reviewEnabled: task.review,
|
|
195
|
+
readOnly: task.readOnly || undefined,
|
|
196
|
+
});
|
|
197
|
+
titles.push(added.title);
|
|
198
|
+
}
|
|
199
|
+
sessions.push({ id: created.id, name: created.name, tasks: titles.length, titles });
|
|
200
|
+
}
|
|
201
|
+
return { sessions, taskCount: sessions.reduce((n, s) => n + s.tasks, 0), warnings };
|
|
202
|
+
}
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of a plan: sessions and their tasks, as one JSON document.
|
|
3
|
+
*
|
|
4
|
+
* The document is not written by hand. The user takes a brief (see `brief.ts`) to whatever
|
|
5
|
+
* chat model they like, that model interviews them about the work, and it answers with a
|
|
6
|
+
* document in this shape. So the schema has two audiences at once: the importer, which needs
|
|
7
|
+
* it to be exact, and a language model, which needs the refusals to say what to fix. That is
|
|
8
|
+
* why nothing here fails with "invalid input" — every issue comes back with a path into the
|
|
9
|
+
* document and a sentence that can be pasted straight back into the chat.
|
|
10
|
+
*
|
|
11
|
+
* Unknown fields are kept out of the imported data but do not fail the import. A model that
|
|
12
|
+
* adds `"description"` to a task has not misunderstood the format badly enough to be worth
|
|
13
|
+
* sending the user back to the chat; it is reported as a warning instead.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
export const PLAN_VERSION = 1;
|
|
17
|
+
/**
|
|
18
|
+
* Version control, exactly as a session stores it, and required on every session.
|
|
19
|
+
*
|
|
20
|
+
* Required because this is the one decision in a plan that cannot be given a safe default. On
|
|
21
|
+
* means the runner branches and commits and there is a way back from every task; off means the
|
|
22
|
+
* files are changed where they lie and there is not. Guessing either way produces a plan that
|
|
23
|
+
* looks fine and is wrong about the thing that matters most, so the model writing the plan has
|
|
24
|
+
* to ask, and a document that skips the question is refused with the question in it.
|
|
25
|
+
*/
|
|
26
|
+
const VcsInput = z
|
|
27
|
+
.object({
|
|
28
|
+
enabled: z.boolean({
|
|
29
|
+
error: 'vcs.enabled must be true or false. Ask the user whether the runner should make a branch before each task and a commit after it; there is no default for this.',
|
|
30
|
+
}),
|
|
31
|
+
repoDir: z.string().trim().default(''),
|
|
32
|
+
branchMode: z
|
|
33
|
+
.enum(['per-task', 'per-session'], {
|
|
34
|
+
error: 'branchMode must be "per-task" (every task starts from the same commit) or "per-session" (one branch, tasks build on each other).',
|
|
35
|
+
})
|
|
36
|
+
.optional(),
|
|
37
|
+
commitOnFinish: z.boolean().optional(),
|
|
38
|
+
branchPrefix: z.string().optional(),
|
|
39
|
+
/** The name of the one branch a per-session run works on. Ignored in per-task mode. */
|
|
40
|
+
branchName: z.string().optional(),
|
|
41
|
+
}, {
|
|
42
|
+
error: 'Every session needs a "vcs" object saying whether the runner does version control for it and, when it does, which git repository to work in. Ask the user both questions and write the answers here.',
|
|
43
|
+
})
|
|
44
|
+
.superRefine((vcs, ctx) => {
|
|
45
|
+
if (vcs.enabled && !vcs.repoDir.trim()) {
|
|
46
|
+
ctx.addIssue({
|
|
47
|
+
code: 'custom',
|
|
48
|
+
path: ['repoDir'],
|
|
49
|
+
message: 'Version control is on for this session, so "repoDir" must be the absolute path of the git repository to work in. Ask the user which folder that is; do not guess one.',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* One condition that has to hold before the task is allowed to end.
|
|
55
|
+
*
|
|
56
|
+
* The kinds are deliberately few and deliberately mechanical. This runner has no language
|
|
57
|
+
* model of its own, so a check it cannot decide by running something and comparing is a check
|
|
58
|
+
* it would have to take on trust — which is the thing the check exists to replace.
|
|
59
|
+
*/
|
|
60
|
+
export const CheckInput = z
|
|
61
|
+
.object({
|
|
62
|
+
name: z.string().trim().min(3, 'A check needs a name saying what it is checking.'),
|
|
63
|
+
expect: z.enum([
|
|
64
|
+
'exit-zero',
|
|
65
|
+
'exit-nonzero',
|
|
66
|
+
'output-contains',
|
|
67
|
+
'output-omits',
|
|
68
|
+
'output-matches',
|
|
69
|
+
'file-exists',
|
|
70
|
+
'file-missing',
|
|
71
|
+
'file-contains',
|
|
72
|
+
], {
|
|
73
|
+
error: 'expect must be one of: exit-zero, exit-nonzero, output-contains, output-omits, output-matches, ' +
|
|
74
|
+
'file-exists, file-missing, file-contains.',
|
|
75
|
+
}),
|
|
76
|
+
run: z.string().trim().optional(),
|
|
77
|
+
/**
|
|
78
|
+
* The shell to run it in. Left out is the usual answer and the better one.
|
|
79
|
+
*
|
|
80
|
+
* A check that names a shell is asking for that shell and nothing else: on a machine without
|
|
81
|
+
* it the check does not quietly run somewhere else, it ends its task with a configuration
|
|
82
|
+
* error saying what is missing. Left out, it takes whatever the machine has, in the order
|
|
83
|
+
* pwsh, powershell, cmd. See `exec/shells.ts`.
|
|
84
|
+
*/
|
|
85
|
+
shell: z.enum(['pwsh', 'powershell', 'cmd']).optional(),
|
|
86
|
+
cwd: z.string().trim().optional(),
|
|
87
|
+
file: z.string().trim().optional(),
|
|
88
|
+
value: z.string().optional(),
|
|
89
|
+
})
|
|
90
|
+
.superRefine((check, ctx) => {
|
|
91
|
+
const needsCommand = check.expect.startsWith('exit-') || check.expect.startsWith('output-');
|
|
92
|
+
const needsFile = check.expect.startsWith('file-');
|
|
93
|
+
const needsValue = check.expect === 'output-contains' || check.expect === 'output-omits' ||
|
|
94
|
+
check.expect === 'output-matches' || check.expect === 'file-contains';
|
|
95
|
+
if (needsCommand && !check.run?.trim()) {
|
|
96
|
+
ctx.addIssue({ code: 'custom', path: ['run'], message: `"${check.expect}" is about a command, so "run" is required.` });
|
|
97
|
+
}
|
|
98
|
+
if (needsFile && !check.file?.trim()) {
|
|
99
|
+
ctx.addIssue({ code: 'custom', path: ['file'], message: `"${check.expect}" is about a file, so "file" is required.` });
|
|
100
|
+
}
|
|
101
|
+
if (needsValue && !check.value?.trim()) {
|
|
102
|
+
ctx.addIssue({
|
|
103
|
+
code: 'custom',
|
|
104
|
+
path: ['value'],
|
|
105
|
+
message: `"${check.expect}" needs the text it is looking for, in "value".`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (check.expect === 'output-matches' && check.value) {
|
|
109
|
+
try {
|
|
110
|
+
new RegExp(check.value);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
ctx.addIssue({ code: 'custom', path: ['value'], message: `not a valid regular expression: ${e.message}` });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
/**
|
|
118
|
+
* Whether the work is checked by a second, independent conversation.
|
|
119
|
+
*
|
|
120
|
+
* Optional, and on when it is left out — which is the point of leaving it out. A plan that says
|
|
121
|
+
* nothing about reviewing still gets reviewed; a plan that wants a particular model for the
|
|
122
|
+
* review, or wants it off for a session of read-only checks, says so here.
|
|
123
|
+
*/
|
|
124
|
+
const ReviewInput = z.object({
|
|
125
|
+
enabled: z.boolean().optional(),
|
|
126
|
+
/** The model the review runs on. Empty means the session's own. A different one is better. */
|
|
127
|
+
model: z.string().trim().optional(),
|
|
128
|
+
});
|
|
129
|
+
/** What one task carries into git: the names, not the outcome. */
|
|
130
|
+
const TaskVcsInput = z.object({
|
|
131
|
+
branch: z.string().trim().optional(),
|
|
132
|
+
commitMessage: z.string().trim().optional(),
|
|
133
|
+
});
|
|
134
|
+
/** The project files copied into the chat. Absent means none, which is the default. */
|
|
135
|
+
const MirrorInput = z.object({
|
|
136
|
+
enabled: z.boolean().optional(),
|
|
137
|
+
rootDir: z.string().optional(),
|
|
138
|
+
includeDirs: z.array(z.string()).optional(),
|
|
139
|
+
excludeDirs: z.array(z.string()).optional(),
|
|
140
|
+
respectGitignore: z.boolean().optional(),
|
|
141
|
+
includeEnvFiles: z.boolean().optional(),
|
|
142
|
+
});
|
|
143
|
+
const TaskInput = z.object({
|
|
144
|
+
title: z
|
|
145
|
+
.string()
|
|
146
|
+
.trim()
|
|
147
|
+
.min(3, 'A task needs a title of at least 3 characters.')
|
|
148
|
+
.max(120, 'Keep the title under 120 characters; the detail belongs in "prompt".'),
|
|
149
|
+
prompt: z
|
|
150
|
+
.string()
|
|
151
|
+
.trim()
|
|
152
|
+
.min(30, 'A task needs a real instruction, not a few words. Say what to do, where, and with what.'),
|
|
153
|
+
/**
|
|
154
|
+
* What must work once the task is done.
|
|
155
|
+
*
|
|
156
|
+
* Separate from the prompt because it is the thing the operator reads the summary against.
|
|
157
|
+
* The importer appends it to the prompt under its own heading, so Copilot receives one text
|
|
158
|
+
* and the record still shows which half was the instruction and which was the bar.
|
|
159
|
+
*/
|
|
160
|
+
expected: z.string().trim().default(''),
|
|
161
|
+
/** Level 2 for this task alone. Falls back to the session's. */
|
|
162
|
+
level2: z.string().trim().default(''),
|
|
163
|
+
/**
|
|
164
|
+
* The branch this task works on and the commit it ends with.
|
|
165
|
+
*
|
|
166
|
+
* Both are names for work that has not happened yet, which is exactly what a plan is good
|
|
167
|
+
* at: the model writing it knows what the task is for. Neither is required, and neither is
|
|
168
|
+
* trusted — the branch name is slugified and prefixed like any other, and the runner still
|
|
169
|
+
* decides when to branch and when to commit.
|
|
170
|
+
*/
|
|
171
|
+
vcs: TaskVcsInput.optional(),
|
|
172
|
+
/**
|
|
173
|
+
* What must be true before this task is allowed to end.
|
|
174
|
+
*
|
|
175
|
+
* `expected` says it for a person to read; this says it so the runner can decide it. When
|
|
176
|
+
* there are checks, Copilot reporting the task as done is not the end of it: they are run,
|
|
177
|
+
* and a failure goes back to the chat to be fixed.
|
|
178
|
+
*/
|
|
179
|
+
checks: z.array(CheckInput).default([]),
|
|
180
|
+
/**
|
|
181
|
+
* Turns the independent review off for this one task.
|
|
182
|
+
*
|
|
183
|
+
* Worth using sparingly: a task whose whole output is a report somebody reads, or a scaffold
|
|
184
|
+
* with nothing to run yet, gains little from a reviewer. Everything that produces something
|
|
185
|
+
* usable gains a lot.
|
|
186
|
+
*/
|
|
187
|
+
review: z.boolean().optional(),
|
|
188
|
+
/**
|
|
189
|
+
* A task that must not change files: an audit, a smoke test, a report.
|
|
190
|
+
*
|
|
191
|
+
* Written as a flag rather than as a sentence in the prompt because a sentence is advice.
|
|
192
|
+
* A smoke-test task told in prose to change nothing renamed the page's labels when a
|
|
193
|
+
* reviewer asked it to. With the flag, the runner fails the task if the working tree has
|
|
194
|
+
* changed when it ends, whatever the summary says, and commits the change on the task's
|
|
195
|
+
* branch so nothing is lost and the next task starts clean.
|
|
196
|
+
*/
|
|
197
|
+
readOnly: z.boolean().default(false),
|
|
198
|
+
});
|
|
199
|
+
const SessionInput = z.object({
|
|
200
|
+
name: z
|
|
201
|
+
.string()
|
|
202
|
+
.trim()
|
|
203
|
+
.min(2, 'A session needs a name.')
|
|
204
|
+
.max(80, 'Keep the session name short; it becomes part of every branch name.'),
|
|
205
|
+
/** What the whole session is for. Becomes the opening of its level 2. */
|
|
206
|
+
goal: z.string().trim().default(''),
|
|
207
|
+
/** The chat model, by the exact name the picker shows. Empty leaves the chat alone. */
|
|
208
|
+
model: z.string().trim().default(''),
|
|
209
|
+
/** Whether the tasks are one chain (`stop`) or independent checks (`continue`). */
|
|
210
|
+
onFailure: z
|
|
211
|
+
.enum(['stop', 'continue'], {
|
|
212
|
+
error: 'onFailure must be "stop" (the tasks are one chain) or "continue" (the tasks are independent).',
|
|
213
|
+
})
|
|
214
|
+
.default('stop'),
|
|
215
|
+
/** Level 2 for every task in the session, unless a task overrides it. */
|
|
216
|
+
level2: z.string().trim().default(''),
|
|
217
|
+
/**
|
|
218
|
+
* A name that makes this session share one Copilot conversation with the others that carry
|
|
219
|
+
* the same name. Empty means it gets a conversation of its own.
|
|
220
|
+
*/
|
|
221
|
+
conversationGroup: z.string().trim().default(''),
|
|
222
|
+
vcs: VcsInput,
|
|
223
|
+
review: ReviewInput.optional(),
|
|
224
|
+
mirror: MirrorInput.optional(),
|
|
225
|
+
tasks: z.array(TaskInput).min(1, 'A session with no tasks would import as an empty queue.'),
|
|
226
|
+
});
|
|
227
|
+
export const PlanSchema = z.object({
|
|
228
|
+
version: z.literal(PLAN_VERSION, `This importer reads version ${PLAN_VERSION} plans. Set "version": ${PLAN_VERSION}.`),
|
|
229
|
+
/** A name for the whole document, shown while importing. */
|
|
230
|
+
plan: z.string().trim().default(''),
|
|
231
|
+
/** Anything the model wants on the record: assumptions, open questions, ordering. */
|
|
232
|
+
notes: z.string().trim().default(''),
|
|
233
|
+
/**
|
|
234
|
+
* What a run across these sessions does when a whole session fails.
|
|
235
|
+
*
|
|
236
|
+
* The same question the sessions ask about their own tasks, one level up. It is here rather
|
|
237
|
+
* than only in the run panel because it is a property of the work: whether session 2 is
|
|
238
|
+
* worth running after session 1 failed is something the plan knows and the operator would
|
|
239
|
+
* otherwise have to remember.
|
|
240
|
+
*/
|
|
241
|
+
onFailure: z
|
|
242
|
+
.enum(['stop', 'continue'], {
|
|
243
|
+
error: 'The plan-level onFailure must be "stop" (a failed session stops the rest) or "continue" (the sessions are independent of each other).',
|
|
244
|
+
})
|
|
245
|
+
.default('stop'),
|
|
246
|
+
/**
|
|
247
|
+
* Whether the sessions of this plan talk to Copilot in one conversation or in their own.
|
|
248
|
+
*
|
|
249
|
+
* `per-session` is the default and the usual answer: a session is a conversation. `shared`
|
|
250
|
+
* puts all of them in one chat, which is what you want when they are one piece of work split
|
|
251
|
+
* into parts that need to see each other's history — and what you do not want when they are
|
|
252
|
+
* unrelated, because then every session inherits a context that has nothing to do with it.
|
|
253
|
+
*/
|
|
254
|
+
conversation: z
|
|
255
|
+
.enum(['per-session', 'shared'], {
|
|
256
|
+
error: 'conversation must be "per-session" (each session gets its own chat) or "shared" (all of them in one).',
|
|
257
|
+
})
|
|
258
|
+
.default('per-session'),
|
|
259
|
+
sessions: z.array(SessionInput).min(1, 'A plan needs at least one session.'),
|
|
260
|
+
});
|
|
261
|
+
/**
|
|
262
|
+
* Finds the JSON inside whatever the user pasted.
|
|
263
|
+
*
|
|
264
|
+
* Chat models wrap answers in prose and fenced code blocks, and asking the user to clean that
|
|
265
|
+
* up by hand is asking them to do the one part of this that a computer is good at. A fenced
|
|
266
|
+
* block wins if there is one; otherwise the text from the first brace to the last is tried.
|
|
267
|
+
*/
|
|
268
|
+
export function extractJson(text) {
|
|
269
|
+
const trimmed = (text ?? '').trim();
|
|
270
|
+
const fenced = /```(?:json|jsonc)?\s*\n([\s\S]*?)\n?```/i.exec(trimmed);
|
|
271
|
+
if (fenced?.[1]?.trim())
|
|
272
|
+
return fenced[1].trim();
|
|
273
|
+
const first = trimmed.indexOf('{');
|
|
274
|
+
const last = trimmed.lastIndexOf('}');
|
|
275
|
+
if (first >= 0 && last > first)
|
|
276
|
+
return trimmed.slice(first, last + 1);
|
|
277
|
+
return trimmed;
|
|
278
|
+
}
|
|
279
|
+
/** `["sessions", 0, "tasks", 1, "prompt"]` as `sessions[0].tasks[1].prompt`. */
|
|
280
|
+
function pathOf(parts) {
|
|
281
|
+
return parts.reduce((acc, p) => (typeof p === 'number' ? `${acc}[${p}]` : acc ? `${acc}.${String(p)}` : String(p)), '');
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The keys of an object schema, read from the schema rather than listed by hand.
|
|
285
|
+
*
|
|
286
|
+
* The list used to be written out here, and it drifted: `readOnly` was added to the task
|
|
287
|
+
* schema and not to the list, so every plan that used it was told "readOnly is not part of the
|
|
288
|
+
* format and was ignored" — while the importer applied it. A warning that contradicts what
|
|
289
|
+
* happens is worse than none. Reading the shape cannot drift.
|
|
290
|
+
*/
|
|
291
|
+
function keysOf(schema, fallback = []) {
|
|
292
|
+
const shape = schema.shape;
|
|
293
|
+
return shape ? Object.keys(shape) : fallback;
|
|
294
|
+
}
|
|
295
|
+
const KNOWN_KEYS = {
|
|
296
|
+
plan: keysOf(PlanSchema),
|
|
297
|
+
session: keysOf(SessionInput),
|
|
298
|
+
task: keysOf(TaskInput),
|
|
299
|
+
check: keysOf(CheckInput, ['name', 'expect', 'run', 'shell', 'cwd', 'file', 'value']),
|
|
300
|
+
vcs: keysOf(VcsInput),
|
|
301
|
+
taskVcs: keysOf(TaskVcsInput),
|
|
302
|
+
review: keysOf(ReviewInput),
|
|
303
|
+
mirror: keysOf(MirrorInput),
|
|
304
|
+
};
|
|
305
|
+
function unknownKeys(value, kind, where, out) {
|
|
306
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
307
|
+
return;
|
|
308
|
+
for (const key of Object.keys(value)) {
|
|
309
|
+
if (!KNOWN_KEYS[kind].includes(key)) {
|
|
310
|
+
out.push(`${where}.${key} is not part of the format and was ignored.`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** Fields a model invented, walked by hand because the schema drops them silently. */
|
|
315
|
+
function collectWarnings(raw) {
|
|
316
|
+
const out = [];
|
|
317
|
+
if (!raw || typeof raw !== 'object')
|
|
318
|
+
return out;
|
|
319
|
+
unknownKeys(raw, 'plan', 'plan', out);
|
|
320
|
+
const sessions = raw.sessions;
|
|
321
|
+
if (!Array.isArray(sessions))
|
|
322
|
+
return out;
|
|
323
|
+
sessions.forEach((s, i) => {
|
|
324
|
+
unknownKeys(s, 'session', `sessions[${i}]`, out);
|
|
325
|
+
const session = s;
|
|
326
|
+
unknownKeys(session.vcs, 'vcs', `sessions[${i}].vcs`, out);
|
|
327
|
+
unknownKeys(session.review, 'review', `sessions[${i}].review`, out);
|
|
328
|
+
unknownKeys(session.mirror, 'mirror', `sessions[${i}].mirror`, out);
|
|
329
|
+
if (Array.isArray(session.tasks)) {
|
|
330
|
+
session.tasks.forEach((t, j) => {
|
|
331
|
+
unknownKeys(t, 'task', `sessions[${i}].tasks[${j}]`, out);
|
|
332
|
+
unknownKeys(t?.vcs, 'taskVcs', `sessions[${i}].tasks[${j}].vcs`, out);
|
|
333
|
+
const checks = t?.checks;
|
|
334
|
+
if (Array.isArray(checks)) {
|
|
335
|
+
checks.forEach((c, k) => unknownKeys(c, 'check', `sessions[${i}].tasks[${j}].checks[${k}]`, out));
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Two sessions with the same name are legal but almost never meant, and by the time anyone
|
|
344
|
+
* notices there are two identical rows in the list and two sets of branches in the
|
|
345
|
+
* repository. Said out loud at import time, it costs the user one glance.
|
|
346
|
+
*/
|
|
347
|
+
function duplicateNameWarnings(plan) {
|
|
348
|
+
const seen = new Map();
|
|
349
|
+
const out = [];
|
|
350
|
+
for (const s of plan.sessions) {
|
|
351
|
+
const key = s.name.toLowerCase();
|
|
352
|
+
const n = (seen.get(key) ?? 0) + 1;
|
|
353
|
+
seen.set(key, n);
|
|
354
|
+
if (n === 2)
|
|
355
|
+
out.push(`Two sessions are both called "${s.name}". They will be created as two separate sessions.`);
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
export function summarise(plan) {
|
|
360
|
+
return {
|
|
361
|
+
plan: plan.plan,
|
|
362
|
+
notes: plan.notes,
|
|
363
|
+
checkCount: plan.sessions.reduce((n, s) => n + s.tasks.reduce((m, t) => m + t.checks.length, 0), 0),
|
|
364
|
+
onFailure: plan.onFailure,
|
|
365
|
+
conversation: plan.conversation,
|
|
366
|
+
sessions: plan.sessions.map((s) => ({
|
|
367
|
+
name: s.name,
|
|
368
|
+
tasks: s.tasks.map((t) => t.title),
|
|
369
|
+
model: s.model,
|
|
370
|
+
onFailure: s.onFailure,
|
|
371
|
+
repoDir: s.vcs?.repoDir ?? '',
|
|
372
|
+
})),
|
|
373
|
+
taskCount: plan.sessions.reduce((n, s) => n + s.tasks.length, 0),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Reads a pasted document and says either what it means or what is wrong with it.
|
|
378
|
+
*
|
|
379
|
+
* Never throws. Every way this can fail — not JSON at all, the wrong version, a task with no
|
|
380
|
+
* prompt — is an answer the user is meant to see and hand back to the chat model.
|
|
381
|
+
*/
|
|
382
|
+
export function checkPlan(text) {
|
|
383
|
+
const source = extractJson(text ?? '');
|
|
384
|
+
if (!source.trim()) {
|
|
385
|
+
return { ok: false, warnings: [], issues: [{ path: '', message: 'There is nothing here to import.' }] };
|
|
386
|
+
}
|
|
387
|
+
let raw;
|
|
388
|
+
try {
|
|
389
|
+
raw = JSON.parse(source);
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
return {
|
|
393
|
+
ok: false,
|
|
394
|
+
warnings: [],
|
|
395
|
+
issues: [
|
|
396
|
+
{
|
|
397
|
+
path: '',
|
|
398
|
+
message: `This is not valid JSON: ${e.message}. ` +
|
|
399
|
+
'A common cause is a comment, a trailing comma, or a curly quote the chat inserted.',
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const warnings = collectWarnings(raw);
|
|
405
|
+
const parsed = PlanSchema.safeParse(raw);
|
|
406
|
+
if (!parsed.success) {
|
|
407
|
+
return { ok: false, warnings, issues: parsed.error.issues.map((i) => ({ path: pathOf(i.path), message: i.message })) };
|
|
408
|
+
}
|
|
409
|
+
const weak = weakCheckIssues(parsed.data);
|
|
410
|
+
if (weak.length)
|
|
411
|
+
return { ok: false, warnings, issues: weak };
|
|
412
|
+
return {
|
|
413
|
+
ok: true,
|
|
414
|
+
plan: parsed.data,
|
|
415
|
+
warnings: [...warnings, ...duplicateNameWarnings(parsed.data), ...indexCheckWarnings(parsed.data)],
|
|
416
|
+
summary: summarise(parsed.data),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* A check that asks the git index whether a file the task creates is there can never pass.
|
|
421
|
+
*
|
|
422
|
+
* The runner commits after the task, so during the task a new file is untracked and
|
|
423
|
+
* `git ls-files` does not list it — and the task is forbidden to `git add`. A plan wrote
|
|
424
|
+
* exactly that check ("the seed rule set is tracked") and the task ended blocked after three
|
|
425
|
+
* honest attempts, with the work complete and the file on disk. `file-exists` is the check
|
|
426
|
+
* that was meant; the negative form (`output-omits` on `ls-files`, "node_modules is not
|
|
427
|
+
* tracked") is fine and stays silent.
|
|
428
|
+
*/
|
|
429
|
+
function indexCheckWarnings(plan) {
|
|
430
|
+
const out = [];
|
|
431
|
+
plan.sessions.forEach((s, i) => {
|
|
432
|
+
s.tasks.forEach((t, j) => {
|
|
433
|
+
t.checks.forEach((c, k) => {
|
|
434
|
+
if (c.expect !== 'output-contains' && c.expect !== 'output-matches')
|
|
435
|
+
return;
|
|
436
|
+
if (!/\bgit\b[^|;&]*\bls-files\b/i.test(c.run ?? ''))
|
|
437
|
+
return;
|
|
438
|
+
out.push(`sessions[${i}].tasks[${j}].checks[${k}]: "${c.name}" asks git ls-files to list a file. A file the task creates is ` +
|
|
439
|
+
'untracked until the runner commits after the task, and the task may not git add, so this check cannot pass. ' +
|
|
440
|
+
'Use "file-exists" for a file the task writes; the index is right only for what earlier tasks committed.');
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* A task whose every check would pass with the task's work not done is refused.
|
|
448
|
+
*
|
|
449
|
+
* `file-exists` proves a file, not what is in it; a bare exit code of a test run is 0 when there are
|
|
450
|
+
* no tests at all, and 0 again with only the old ones. A plan built entirely from those can finish
|
|
451
|
+
* "done" with nothing proven, and it did: twice in a row the planning model wrote "npm test exits 0"
|
|
452
|
+
* against five acceptance criteria — the second time after the brief had named this very trap — and
|
|
453
|
+
* both times the operator had to catch it and dictate the fix. The brief teaches the rule; this is
|
|
454
|
+
* the part that does not depend on a model having learned it. It is an error rather than a warning
|
|
455
|
+
* because errors are what the operator copies back to the chat with one button, and the brief tells
|
|
456
|
+
* the chat to fix exactly those.
|
|
457
|
+
*
|
|
458
|
+
* Only tasks that have checks are judged: a task with none (a read-only report, a task with nothing
|
|
459
|
+
* to run) was allowed before and is not what this is about. `exit-nonzero` is not weak — it proves a
|
|
460
|
+
* refusal — and anything that reads output or file contents is where proof can live.
|
|
461
|
+
*/
|
|
462
|
+
const WEAK_EXPECT = new Set(['file-exists', 'file-missing', 'exit-zero']);
|
|
463
|
+
function weakCheckIssues(plan) {
|
|
464
|
+
const out = [];
|
|
465
|
+
plan.sessions.forEach((s, i) => {
|
|
466
|
+
s.tasks.forEach((t, j) => {
|
|
467
|
+
if (!t.checks.length || !t.checks.every((c) => WEAK_EXPECT.has(c.expect)))
|
|
468
|
+
return;
|
|
469
|
+
out.push({
|
|
470
|
+
path: `sessions[${i}].tasks[${j}].checks`,
|
|
471
|
+
message: `every check of "${t.title}" would pass with the task's work not done: a file that exists proves the file, not what is in it, ` +
|
|
472
|
+
'and a test run exits 0 with no tests at all. Add a check that reads output or contents only the finished work produces — ' +
|
|
473
|
+
'name each test after its acceptance criterion and use "output-contains" on the test runner\'s output for that test passing ' +
|
|
474
|
+
'(with node --test, the line starts with ✔), or "output-matches" on the count of passing tests.',
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
/** The issues as a block the user can hand back to the chat model without editing it. */
|
|
481
|
+
export function describeIssues(issues) {
|
|
482
|
+
return issues.map((i) => (i.path ? `- ${i.path}: ${i.message}` : `- ${i.message}`)).join('\n');
|
|
483
|
+
}
|