engineering-memory 1.11.16 → 1.11.18
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/dispatcher/sections.mjs +5 -3
- package/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/config.js +1 -0
- package/runtime/dist/src/mcp/decision-tools.js +119 -0
- package/runtime/dist/src/mcp/questionnaire-tools.js +14 -4
- package/runtime/dist/src/mcp/tool-annotations.js +4 -0
- package/runtime/dist/src/mcp/tool-definitions.js +30 -5
- package/runtime/dist/src/mcp/worktree-tools.js +5 -1
- package/runtime/dist/src/runtime/api-client.js +4 -0
- package/runtime/dist/src/runtime/bridge-service.js +265 -23
- package/runtime/dist/src/runtime/decision-mode-store.js +88 -0
- package/runtime/dist/src/runtime/questionnaire-store.js +27 -3
- package/skill/SKILL.md +5 -3
- package/skill/references/lifecycle.md +15 -3
- package/skill/references/memory-updates.md +6 -2
- package/skill/references/questionnaires.md +20 -2
package/dispatcher/sections.mjs
CHANGED
|
@@ -20,15 +20,17 @@ Never answer a question about a bound repository from the working tree alone. Wh
|
|
|
20
20
|
|
|
21
21
|
After compaction, a new chat, interruption, or handoff, call \`session.resume\` before continuing.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Every new task starts with a short native mode selector through task.branch or session.bootstrap: Autonomous, Approve for me (recommended), or Ask for approval. Resume and compaction retain that task's selection. When the user wants to change mode, read decision.mode_status and open decision.mode at its current version. This selector always needs a real native user answer. Autonomous records reasoned agent decisions; Approve for me delegates routine decisions and asks critical ones; Ask for approval asks every required decision. Follow the skill's questionnaires reference and use questionnaire.decide for delegated_decision_required, comparing alternatives and reviewing the user's interests. Never blindly choose the recommendation or relay agent decisions as native answers. Releases, irreversible changes, access, spending, scope expansion and rule exceptions are critical. A mode never overrides host permissions or the normal verification gates.
|
|
24
|
+
|
|
25
|
+
Before a new write/scaffold task, call \`task.branch\` directly: do not ask the user where, from which branch or under which name first, and do not call \`worktree.list\` for it. After the task mode is selected, its short start decision asks only the starting branch and where to work. It generates the new branch name automatically; do not ask the user to name it unless they request a specific name. Nothing is created until the mode-governed start decision is recorded. The pool reuses a safely available worktree slot before creating another; never pass or invent a worktree path. Reconcile protected legacy entries through the native recovery flow; clean files alone do not establish that the old agent and delivery have finished. Run all commands from its returned \`repoRoot\`. Renew \`task.heartbeat\` during actual work, use \`task.pause\` on handoff, and resume before writing again. Inactivity never permits force checkout or cleanup. Keep closed worktrees until authorized delivery completes. When \`session.entry\` says the user can manage them, unanswered shared branch preferences are asked together in one native form via \`project.set_git_preferences\`; other members choose only their own task base in \`task.branch\`. Answering "Not now" returns \`deferred\` with nothing allocated and a \`reconsider\` call; say so in one line, keep working on anything that does not depend on it, and make that \`reconsider\` call only when the user actually asks to start that task.
|
|
24
26
|
|
|
25
27
|
Do not edit until the skill lifecycle has completed discovery, its checkpoint, and \`context.prepare_change\`. Do not claim completion until \`task.verify\` succeeds.
|
|
26
28
|
|
|
27
|
-
Do not block independent task work on \`memory.propose_revision\` drafting, submission or approval. Follow the skill's memory-updates reference: use background agents or concurrent tools when the host permits them, and continue useful work instead of immediately waiting. Without concurrency, checkpoint the pending draft and defer submission until needed. Collect the result only at the operation that depends on it;
|
|
29
|
+
Do not block independent task work on \`memory.propose_revision\` drafting, submission or approval. Follow the skill's memory-updates reference: use background agents or concurrent tools when the host permits them, and continue useful work instead of immediately waiting. Without concurrency, checkpoint the pending draft and defer submission until needed. Collect the result only at the operation that depends on it; the current mode's decision receipt and required task verification still apply.
|
|
28
30
|
|
|
29
31
|
A check whose answer decides the next action — delivery preconditions such as the commit hook, the remote and the branch; whether verification or close can pass; what a waiver allows — is run by the agent that takes that action, in its own turn. Hand work to another agent only when you have other real work to do while it runs, and never take the action a delegated check gates before its answer is back. Work another agent checked is neither the user's approval nor task verification. Size delegation to the task — a one- or two-file change needs no multi-agent workflow — and once work is delegated, wait for its real completion signal, never a sleep loop polling its output.
|
|
30
32
|
|
|
31
|
-
Choose question detail to suit the decision: task start is short, while complex decisions retain context and examples. Put a deliberate recommended choice first and preserve the host's free-input path without requiring an option or adding a duplicate Other choice; feedback is not consent.
|
|
33
|
+
Choose question detail to suit the decision: task start is short, while complex decisions retain context and examples. Put a deliberate recommended choice first and preserve the host's free-input path without requiring an option or adding a duplicate Other choice; feedback is not consent. When the task mode requires a user answer, use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. For a required decision, use \`questionnaire.ask\` to open a durable native MCP form and \`questionnaire.resume\` to return to the same unanswered question. Read the skill's questionnaires reference first. Write every question in the user's language, say in \`context\` why it is asked, give in \`example\` one concrete thing the decision changes, and give every option a \`description\` of what happens next. Show a record by its title, never by an id or a hash. When the user says they did not understand, that is not an answer: explain in chat and show the same question again. Never use request_user_input_async for a required decision: it does not wait for an answer. When the durable form is unavailable, use a blocking native control only where the host permits it: request_user_input in Codex or AskUserQuestion in Claude. Never replace the questionnaire with a chat instruction such as 'type this', 'reply yes', or 'write X if you want Y'. Do not open a survey web page. If the required native control is unavailable or prohibited for that kind of question, follow the host's tool restrictions, explain the limitation, and continue only work already authorized; do not fabricate a survey or silently choose an answer. A timeout, dismissed form, empty response or ended turn is not an answer. Keep the decision pending and resume it; do not start dependent work or report it as resolved. Existing answers remain valid through retries and handoffs.
|
|
32
34
|
|
|
33
35
|
When a durable MCP form cannot be displayed, read its \`hostFallback\` or call \`questionnaire.resume\` with \`presentation: host_native\` to get the original question without reopening the MCP form. Display the same question, all choices and notices through a blocking native control only if the host permits that control for this decision. After an actual native answer, call \`questionnaire.answer_from_host\` with the unchanged questionnaireId, requestKey and contentHash, the hostTool name and the returned choice/text. This is an agent-reported relay, not MCP transport attestation. Then retry the owning operation; its authority, content and version checks still apply. Never relay prose consent, a default, an asynchronous response or a cancelled/declined/missing answer. Decline alone is not proof that the host cannot display forms. Keep the decision pending if no permitted native control can represent it.
|
|
34
36
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "engineering-memory",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.18",
|
|
4
4
|
"description": "Installs the Engineering Memory skill and its local MCP bridge. Sign in after installing; your organization and project are resolved from your account.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
package/runtime/build.json
CHANGED
|
@@ -77,6 +77,7 @@ export const endpoints = {
|
|
|
77
77
|
projectRestore: (projectId) => `/projects/${projectId}/restore`,
|
|
78
78
|
projectMemberAdd: (projectId) => `/projects/${projectId}/members`,
|
|
79
79
|
projectMemberList: (projectId) => `/projects/${projectId}/members`,
|
|
80
|
+
workItemStatuses: (projectId) => `/projects/${projectId}/work-items/statuses`,
|
|
80
81
|
workItemList: (projectId) => `/projects/${projectId}/work-items`,
|
|
81
82
|
workItemGet: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}`,
|
|
82
83
|
projectLink: '/projects/link',
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { decisionTaskId } from '../runtime/decision-mode-store.js';
|
|
3
|
+
import { delegatedReasonSchema, hostAnswerSchema, languageTag, } from '../runtime/questionnaire-store.js';
|
|
4
|
+
import { askQuestionnaire, answerChoice } from './questionnaire-tools.js';
|
|
5
|
+
const modeInput = z.strictObject({
|
|
6
|
+
repoRoot: z.string().min(1).optional(),
|
|
7
|
+
externalTaskId: decisionTaskId,
|
|
8
|
+
expectedVersion: z.number().int().min(0),
|
|
9
|
+
decisionAttempt: z.number().int().min(1).max(10000).optional(),
|
|
10
|
+
language: languageTag.optional(),
|
|
11
|
+
presentation: z.enum(['host_native']).optional(),
|
|
12
|
+
});
|
|
13
|
+
function result(data) {
|
|
14
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, data }) }] };
|
|
15
|
+
}
|
|
16
|
+
function failure(error) {
|
|
17
|
+
return {
|
|
18
|
+
isError: true,
|
|
19
|
+
content: [
|
|
20
|
+
{
|
|
21
|
+
type: 'text',
|
|
22
|
+
text: JSON.stringify({
|
|
23
|
+
ok: false,
|
|
24
|
+
error: {
|
|
25
|
+
kind: 'decision_mode_error',
|
|
26
|
+
message: error instanceof Error ? error.message : 'Decision mode operation failed.',
|
|
27
|
+
retryable: false,
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function chooseMode(server, service, input, context) {
|
|
35
|
+
const definition = await service.decisionModeQuestion(input);
|
|
36
|
+
const form = await askQuestionnaire(server, service, {
|
|
37
|
+
...definition,
|
|
38
|
+
repoRoot: input.repoRoot,
|
|
39
|
+
presentation: input.presentation,
|
|
40
|
+
}, context, [], {
|
|
41
|
+
tool: 'decision.mode',
|
|
42
|
+
externalTaskId: input.externalTaskId,
|
|
43
|
+
...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
|
|
44
|
+
retryArguments: { expectedVersion: input.expectedVersion, language: definition.language },
|
|
45
|
+
});
|
|
46
|
+
if (!answerChoice(form))
|
|
47
|
+
return { form };
|
|
48
|
+
const state = await service.decisionModeSelect({
|
|
49
|
+
...input,
|
|
50
|
+
questionnaireId: definition.questionnaireId,
|
|
51
|
+
});
|
|
52
|
+
return { state };
|
|
53
|
+
}
|
|
54
|
+
export async function ensureDecisionMode(server, service, input, context) {
|
|
55
|
+
try {
|
|
56
|
+
if ((await service.decisionModeStatus(input)).configured)
|
|
57
|
+
return null;
|
|
58
|
+
const chosen = await chooseMode(server, service, { ...input, expectedVersion: 0 }, context);
|
|
59
|
+
return chosen.form ?? null;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return failure(error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function registerDecisionTools(server, service) {
|
|
66
|
+
server.registerTool('decision.mode_status', {
|
|
67
|
+
description: 'Read the decision mode and version of this exact task. A missing selection defaults to ask and requires the native task-start mode form. Resuming a task keeps its selection; other tasks are independent.',
|
|
68
|
+
inputSchema: modeInput.pick({ repoRoot: true, externalTaskId: true }),
|
|
69
|
+
}, async (input) => {
|
|
70
|
+
try {
|
|
71
|
+
return result(await service.decisionModeStatus(input));
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return failure(error);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
server.registerTool('decision.mode', {
|
|
78
|
+
description: 'Open the three-choice native task mode form at task start or whenever the user wants to change mode. Read decision.mode_status and pass its version as expectedVersion. Always let the user select this mode, including while autonomous. Feedback, dismissal and defaults never select a mode. Retry identical arguments to apply an answered question.',
|
|
79
|
+
inputSchema: modeInput,
|
|
80
|
+
}, async (input, context) => {
|
|
81
|
+
try {
|
|
82
|
+
const chosen = await chooseMode(server, service, input, context);
|
|
83
|
+
return chosen.form ?? result(chosen.state);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return failure(error);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
server.registerTool('questionnaire.decide', {
|
|
90
|
+
description: 'Record a reasoned agent decision only when this task mode delegates it. Read the exact pending question and mode version. Deliberate from the user goals, compare alternatives and independently review their interests; never blindly choose the recommended option. This records delegated_agent provenance, never a user answer. Critical decisions in approve-for-me and all mode switches require native answers. Retry the owning operation afterwards; authority, scope, validation and host permissions still apply.',
|
|
91
|
+
inputSchema: z.strictObject({
|
|
92
|
+
questionnaireId: hostAnswerSchema.shape.questionnaireId,
|
|
93
|
+
requestKey: hostAnswerSchema.shape.requestKey,
|
|
94
|
+
contentHash: hostAnswerSchema.shape.contentHash,
|
|
95
|
+
answer: hostAnswerSchema.shape.answer,
|
|
96
|
+
answers: hostAnswerSchema.shape.answers,
|
|
97
|
+
repoRoot: z.string().min(1).optional(),
|
|
98
|
+
modeVersion: z.number().int().positive(),
|
|
99
|
+
...delegatedReasonSchema.shape,
|
|
100
|
+
}),
|
|
101
|
+
}, async (input) => {
|
|
102
|
+
try {
|
|
103
|
+
const resolved = await service.questionnaireDecide(input);
|
|
104
|
+
return result({
|
|
105
|
+
questionnaireId: resolved.record.questionnaireId,
|
|
106
|
+
status: resolved.record.status,
|
|
107
|
+
answerAvailable: resolved.record.answerAvailable,
|
|
108
|
+
answer: resolved.record.answer,
|
|
109
|
+
answers: resolved.record.answers,
|
|
110
|
+
answerSource: resolved.record.answerSource,
|
|
111
|
+
retry: resolved.retry,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
return failure(error);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=decision-tools.js.map
|
|
@@ -76,12 +76,10 @@ async function present(server, service, record, repoRoot, context, preparation,
|
|
|
76
76
|
if (record.status === 'answered') {
|
|
77
77
|
if (inputResponse(context.mcpReq.inputResponses, record.requestKey).kind !== 'missing')
|
|
78
78
|
consumedResponses(context).add(record.requestKey);
|
|
79
|
-
return answered(record, record.questions ? record.answers : record.answer, true);
|
|
79
|
+
return answered(record, record.questions ? record.answers : record.answer, true, service.questionnaireRetry(record, repoRoot ?? process.cwd()));
|
|
80
80
|
}
|
|
81
81
|
if (context.mcpReq.signal.aborted)
|
|
82
82
|
return pending(record, 'interrupted');
|
|
83
|
-
if (presentation === 'host_native')
|
|
84
|
-
return pending(record, 'host_native_requested');
|
|
85
83
|
const responses = context.mcpReq.inputResponses;
|
|
86
84
|
const response = inputResponse(responses, record.requestKey);
|
|
87
85
|
if (response.kind === 'elicit' && response.action === 'accept') {
|
|
@@ -125,7 +123,7 @@ async function present(server, service, record, repoRoot, context, preparation,
|
|
|
125
123
|
scope: record.scope,
|
|
126
124
|
answer: answer,
|
|
127
125
|
});
|
|
128
|
-
return answered(resolved.record, resolved.answer, resolved.replayed);
|
|
126
|
+
return answered(resolved.record, resolved.answer, resolved.replayed, service.questionnaireRetry(resolved.record, repoRoot ?? process.cwd()));
|
|
129
127
|
}
|
|
130
128
|
if (response.kind === 'elicit')
|
|
131
129
|
return pending(record, response.action);
|
|
@@ -133,6 +131,18 @@ async function present(server, service, record, repoRoot, context, preparation,
|
|
|
133
131
|
context.mcpReq.droppedInputResponseKeys?.length ||
|
|
134
132
|
Object.keys(responses ?? {}).some((key) => !consumedResponses(context).has(key)))
|
|
135
133
|
return pending(record, 'missing_or_stale_response');
|
|
134
|
+
const policy = await service.questionnaireDecisionPolicy(record);
|
|
135
|
+
if (policy.delegated && presentation !== 'host_native') {
|
|
136
|
+
const waiting = pending(record, 'delegated_decision_required');
|
|
137
|
+
const data = JSON.parse(waiting.content[0].text).data;
|
|
138
|
+
return result({
|
|
139
|
+
...data,
|
|
140
|
+
decisionPolicy: policy,
|
|
141
|
+
nextAction: 'Evaluate the choices against the user goals, compare alternatives, and review the choice from their perspective. Call questionnaire.decide with this exact identity, mode version and your reasoning. This is an agent decision, not native user consent. Then retry the owning operation. If approve-for-me and the chosen action is critical, resume the form with presentation host_native.',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (presentation === 'host_native')
|
|
145
|
+
return pending(record, 'host_native_requested');
|
|
136
146
|
const envelope = context.mcpReq.envelope;
|
|
137
147
|
const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY] ?? server.server.getClientCapabilities();
|
|
138
148
|
const elicitation = capabilities?.elicitation;
|
|
@@ -26,6 +26,9 @@ export const toolAnnotations = {
|
|
|
26
26
|
'task.pause': write,
|
|
27
27
|
'worktree.release': destructive,
|
|
28
28
|
'audit.list': read,
|
|
29
|
+
'decision.mode': repeatableWrite,
|
|
30
|
+
'decision.mode_status': read,
|
|
31
|
+
'questionnaire.decide': repeatableWrite,
|
|
29
32
|
'questionnaire.ask': repeatableWrite,
|
|
30
33
|
'questionnaire.resume': repeatableWrite, // records an accepted answer
|
|
31
34
|
'questionnaire.answer_from_host': repeatableWrite,
|
|
@@ -100,6 +103,7 @@ export const toolAnnotations = {
|
|
|
100
103
|
'work_item.confirm_plan': write,
|
|
101
104
|
'project.update': write,
|
|
102
105
|
'work_item.list': read,
|
|
106
|
+
'work_item.statuses': read,
|
|
103
107
|
'work_item.get': read,
|
|
104
108
|
'project.clone': outward,
|
|
105
109
|
'project.link': write,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensureDecisionMode, registerDecisionTools } from './decision-tools.js';
|
|
1
2
|
import * as z from 'zod/v4';
|
|
2
3
|
import { validationIds } from '../runtime/bridge-service.js';
|
|
3
4
|
import { stableStringify } from '../utilities/hash.js';
|
|
@@ -47,6 +48,9 @@ const checkpointBase = {
|
|
|
47
48
|
idempotencyKey: z.string().uuid().optional(),
|
|
48
49
|
};
|
|
49
50
|
export const engineeringMemoryToolNames = [
|
|
51
|
+
'decision.mode',
|
|
52
|
+
'decision.mode_status',
|
|
53
|
+
'questionnaire.decide',
|
|
50
54
|
'project.git_preferences',
|
|
51
55
|
'project.set_git_preferences',
|
|
52
56
|
'worktree.policy',
|
|
@@ -130,6 +134,7 @@ export const engineeringMemoryToolNames = [
|
|
|
130
134
|
'work_item.plan',
|
|
131
135
|
'work_item.confirm_plan',
|
|
132
136
|
'project.update',
|
|
137
|
+
'work_item.statuses',
|
|
133
138
|
'work_item.list',
|
|
134
139
|
'work_item.get',
|
|
135
140
|
'project.clone',
|
|
@@ -190,6 +195,7 @@ const waiverCopy = {
|
|
|
190
195
|
},
|
|
191
196
|
};
|
|
192
197
|
export function registerQuestionnaireTools(server, service) {
|
|
198
|
+
registerDecisionTools(server, service);
|
|
193
199
|
server.registerTool('questionnaire.answer_from_host', {
|
|
194
200
|
description: 'Relay an actual answer from a host-permitted blocking native AskUserQuestion or request_user_input control to the exact durable question. First read hostFallback from the pending operation or questionnaire.resume with presentation host_native, display its unchanged question/options/notices and await the real native result. Never infer answers, use chat consent, asynchronous controls, defaults or a declined/dismissed form. The hostTool field is agent-reported provenance, not transport-verified attestation. Bind requestKey and contentHash exactly; changed scope, withdrawn questions, invalid or conflicting answers are refused. This only saves the answer; retry the owning operation for current authority and version checks.',
|
|
195
201
|
inputSchema: hostAnswerSchema.extend({
|
|
@@ -228,7 +234,7 @@ export function registerQuestionnaireTools(server, service) {
|
|
|
228
234
|
}
|
|
229
235
|
});
|
|
230
236
|
server.registerTool('questionnaire.ask', {
|
|
231
|
-
description: 'Persist a required decision before displaying a native questionnaire. Write the message, context, example, labels and option descriptions in the language the user writes in, and set language to its BCP-47 tag (tr, en, pt-BR); it is remembered, and the call is refused while no language is known. Say why the decision is asked in context, give one concrete example of what it changes in example, and give every option a description of what happens next. Show a record by its title, never by an id or a hash. Use a questionnaireId unique to this decision occurrence or task, and reuse it only for identical retries. A later decision or changed wording/options requires a new id.
|
|
237
|
+
description: 'Persist a required decision before displaying a native questionnaire. Write the message, context, example, labels and option descriptions in the language the user writes in, and set language to its BCP-47 tag (tr, en, pt-BR); it is remembered, and the call is refused while no language is known. Say why the decision is asked in context, give one concrete example of what it changes in example, and give every option a description of what happens next. Show a record by its title, never by an id or a hash. Use a questionnaireId unique to this decision occurrence or task, and reuse it only for identical retries. A later decision or changed wording/options requires a new id. An answer is either a schema-validated native acceptance or a reasoned questionnaire.decide receipt permitted by the selected task mode. Dismissal, timeout and missing replies remain pending without expiry. Never supply answers in questionnaire.ask arguments; use questionnaire.answer_from_host only after an actual permitted blocking native answer. Do not put personal information or secrets in the question or options. Free text is returned once and never stored; use explicit options for replayable decisions. After the first decline in this session, pass presentation host_native to skip the doomed elicitation round trip.',
|
|
232
238
|
inputSchema: questionnaireDefinitionSchema.extend({
|
|
233
239
|
repoRoot: optionalRepoRoot,
|
|
234
240
|
preparation: z.boolean().optional(),
|
|
@@ -329,9 +335,11 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
329
335
|
}),
|
|
330
336
|
}, async (input) => toolResult(await service.sessionAnswerShadowNotice(input)));
|
|
331
337
|
server.registerTool('session.bootstrap', {
|
|
332
|
-
description: '
|
|
338
|
+
description: 'First select the decision mode of a new task through a short native form, then authenticate, resolve the repository project, open or resume a write or read-only task, and load mandatory engineering context before planning. objective is one line of at most 240 characters. contextPack holds only what fit the response budget; deferredResources lists what did not, each entry carrying its revisionId so it can be read directly with memory.read_revisions rather than looked up again through memory.catalog.',
|
|
333
339
|
inputSchema: z.object({
|
|
334
340
|
repoRoot: optionalRepoRoot,
|
|
341
|
+
language: languageTag.optional(),
|
|
342
|
+
presentation: z.enum(['host_native']).optional(),
|
|
335
343
|
projectId: z.string().optional(),
|
|
336
344
|
externalTaskId: z
|
|
337
345
|
.string()
|
|
@@ -367,7 +375,13 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
367
375
|
.optional(),
|
|
368
376
|
knownRevisions: z.record(z.string(), z.number().int().min(0)).optional(),
|
|
369
377
|
}),
|
|
370
|
-
}, async (input) =>
|
|
378
|
+
}, async (input, context) => {
|
|
379
|
+
const mode = await ensureDecisionMode(server, service, input, context);
|
|
380
|
+
if (mode)
|
|
381
|
+
return mode;
|
|
382
|
+
const { language: _language, presentation: _presentation, ...bootstrap } = input;
|
|
383
|
+
return toolResult(await service.sessionBootstrap(bootstrap));
|
|
384
|
+
});
|
|
371
385
|
server.registerTool('session.resume', {
|
|
372
386
|
description: 'Merge backend events, local journal, offline outbox, pinned context and current Git state after a new chat or context compaction.',
|
|
373
387
|
inputSchema: z.object({
|
|
@@ -932,7 +946,7 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
932
946
|
}),
|
|
933
947
|
}, async (input) => toolResult(await service.workItemCreate(input)));
|
|
934
948
|
server.registerTool('work_item.update', {
|
|
935
|
-
description:
|
|
949
|
+
description: 'Edit or assign a work item at its current version. Assignees must already be active project members; this does not grant project access. Read work_item.statuses before changing status. Send the actual project catalogue slug and follow its user-defined meaning, entryRule and flags; never infer workflow from a status name.',
|
|
936
950
|
inputSchema: z.object({
|
|
937
951
|
...workItemLocator,
|
|
938
952
|
data: z.object({
|
|
@@ -1015,11 +1029,22 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
1015
1029
|
}),
|
|
1016
1030
|
}),
|
|
1017
1031
|
}, async (input) => toolResult(await service.projectUpdate(input)));
|
|
1032
|
+
server.registerTool('work_item.statuses', {
|
|
1033
|
+
description: 'Read the project status catalogue before choosing a work item status. Follow the user-defined meaning, entryRule and flags; never infer workflow from status names. Page with offset and limit (defaults 0 and 50, maximum 100). Archived statuses are excluded unless includeArchived is true.',
|
|
1034
|
+
inputSchema: z.object({
|
|
1035
|
+
projectId: z.string().uuid(),
|
|
1036
|
+
offset: z.number().int().min(0).optional(),
|
|
1037
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
1038
|
+
includeArchived: z.boolean().optional(),
|
|
1039
|
+
}),
|
|
1040
|
+
}, async (input) => toolResult(await service.workItemStatuses(input)));
|
|
1018
1041
|
server.registerTool('work_item.list', {
|
|
1019
|
-
description:
|
|
1042
|
+
description: 'List selectable work items for a project before opening an engineering run in this chat. Read work_item.statuses for the actual project catalogue slugs and user-defined meanings; never infer workflow from status names. The status filter matches an actual slug, including a retained archived status; an unknown slug returns an empty page.',
|
|
1020
1043
|
inputSchema: z.object({
|
|
1021
1044
|
projectId: z.string().uuid(),
|
|
1022
1045
|
status: z.string().trim().min(1).max(64).optional(),
|
|
1046
|
+
offset: z.number().int().min(0).optional(),
|
|
1047
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
1023
1048
|
priority: z.enum(['lowest', 'low', 'medium', 'high', 'highest']).optional(),
|
|
1024
1049
|
assigneeUserId: z.string().uuid().optional(),
|
|
1025
1050
|
includeArchived: z.boolean().optional(),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensureDecisionMode } from './decision-tools.js';
|
|
1
2
|
import { basename } from 'node:path';
|
|
2
3
|
import * as z from 'zod/v4';
|
|
3
4
|
import { cancelledDeliveryQuestionId, forgetUnreadableQuestionId, retireQuestionId, } from '../runtime/bridge-service.js';
|
|
@@ -358,7 +359,7 @@ export function registerWorktreeTools(server, service) {
|
|
|
358
359
|
inputSchema: z.strictObject(policy),
|
|
359
360
|
}, async (input) => output(await service.worktreePolicy(json(input))));
|
|
360
361
|
server.registerTool('task.branch', {
|
|
361
|
-
description: 'Start a write task
|
|
362
|
+
description: 'Start a write task by selecting its decision mode in one short native form, then resolve two short questions for the starting branch (unless base is given) and the folder. A unique branch name is generated automatically unless name is supplied. Folder options: a separate managed worktree, this folder as a new branch when it is clean (moving out an unfinished task that holds it, named in the option), this folder on its current branch, or not now. Nothing is created until a valid native or mode-delegated start answer is recorded; the next call with the same arguments allocates. A remote base is fetched and its exact commit pinned. Retry/resume preserves the allocation. Not now returns status deferred with the exact reconsider call; retries keep the same decisionAttempt. Use the returned repoRoot for ALL commands. A read-only task allocates only when transitioning to write.',
|
|
362
363
|
inputSchema: z.strictObject({
|
|
363
364
|
repoRoot: repo,
|
|
364
365
|
externalTaskId: z.string().min(2).max(160),
|
|
@@ -377,6 +378,9 @@ export function registerWorktreeTools(server, service) {
|
|
|
377
378
|
const flight = preflight.data;
|
|
378
379
|
if (flight.existingAllocation)
|
|
379
380
|
return output(await service.taskBranch(input));
|
|
381
|
+
const mode = await ensureDecisionMode(server, service, input, context);
|
|
382
|
+
if (mode)
|
|
383
|
+
return mode;
|
|
380
384
|
const role = input.base?.kind;
|
|
381
385
|
if ((role === 'development' || role === 'production' || role === 'test') &&
|
|
382
386
|
!configuredBase(flight.preferences, role))
|
|
@@ -20,6 +20,7 @@ export const backendRecoveryOperationNames = [
|
|
|
20
20
|
'organization.list',
|
|
21
21
|
'project.member_add',
|
|
22
22
|
'work_item.list',
|
|
23
|
+
'work_item.statuses',
|
|
23
24
|
'work_item.get',
|
|
24
25
|
'session.bootstrap',
|
|
25
26
|
'session.resume',
|
|
@@ -90,6 +91,9 @@ export class ApiClient {
|
|
|
90
91
|
this.options = options;
|
|
91
92
|
this.fetchImplementation = options.fetchImplementation ?? fetch;
|
|
92
93
|
}
|
|
94
|
+
get namespace() {
|
|
95
|
+
return sha256(this.options.baseUrl.replace(/\/+$/, ''));
|
|
96
|
+
}
|
|
93
97
|
async request(path, request = {}) {
|
|
94
98
|
const method = request.method ?? 'GET';
|
|
95
99
|
const cacheKey = request.cacheKey ?? this.cacheKey(method, path, request.body);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { decisionModeSchema } from './decision-mode-store.js';
|
|
2
|
+
import { delegatedReasonSchema } from './questionnaire-store.js';
|
|
1
3
|
import { unreadableWorktree, } from './worktree-pool.js';
|
|
2
4
|
import { validWorktreePolicy } from './worktree-policy.js';
|
|
3
5
|
import { resolveTaskBase } from './branch-preferences.js';
|
|
@@ -57,6 +59,202 @@ export class BridgeService {
|
|
|
57
59
|
await this.dependencies.languages.remember(principalHash, told);
|
|
58
60
|
return told ?? (await this.dependencies.languages.read(principalHash));
|
|
59
61
|
}
|
|
62
|
+
async decisionModeStatus(input) {
|
|
63
|
+
const scope = await this.questionnaireScope(input.repoRoot);
|
|
64
|
+
return this.dependencies.questionnaires.decisions.read(scope, this.dependencies.client.namespace, input.externalTaskId);
|
|
65
|
+
}
|
|
66
|
+
async decisionModeQuestion(input) {
|
|
67
|
+
const scope = await this.questionnaireScope(input.repoRoot);
|
|
68
|
+
if (!scope.projectId || !(await this.dependencies.credentials.get('access-token')))
|
|
69
|
+
throw refuse('Select a signed-in project before choosing a task decision mode.', 'session.entry');
|
|
70
|
+
const namespace = this.dependencies.client.namespace;
|
|
71
|
+
const id = 'decision-mode-' +
|
|
72
|
+
sha256(stableStringify({
|
|
73
|
+
scope,
|
|
74
|
+
namespace,
|
|
75
|
+
task: input.externalTaskId,
|
|
76
|
+
version: input.expectedVersion,
|
|
77
|
+
...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
|
|
78
|
+
}));
|
|
79
|
+
const current = await this.decisionModeStatus(input);
|
|
80
|
+
if (current.version !== input.expectedVersion &&
|
|
81
|
+
!(current.version === input.expectedVersion + 1 && current.questionnaireId === id))
|
|
82
|
+
throw refuse('The mode changed. Read decision.mode_status before opening another mode question.', 'decision.mode_status');
|
|
83
|
+
const language = await this.language(input.language);
|
|
84
|
+
const existing = await this.dependencies.questionnaires.get(scope, id);
|
|
85
|
+
if (existing) {
|
|
86
|
+
if (existing.owner?.tool !== 'decision.mode' ||
|
|
87
|
+
existing.owner.externalTaskId !== input.externalTaskId)
|
|
88
|
+
throw refuse('This mode question has a different owner. Start a new decisionAttempt.', 'decision.mode');
|
|
89
|
+
if (existing.status === 'withdrawn')
|
|
90
|
+
throw refuse('The mode question was withdrawn. When the user resumes this choice, increment decisionAttempt without changing expectedVersion.', 'decision.mode');
|
|
91
|
+
return {
|
|
92
|
+
questionnaireId: id,
|
|
93
|
+
message: existing.message,
|
|
94
|
+
context: existing.context,
|
|
95
|
+
example: existing.example,
|
|
96
|
+
language: existing.language,
|
|
97
|
+
options: existing.options,
|
|
98
|
+
binding: existing.binding,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const tr = language?.startsWith('tr');
|
|
102
|
+
const definition = {
|
|
103
|
+
questionnaireId: id,
|
|
104
|
+
message: tr
|
|
105
|
+
? input.expectedVersion
|
|
106
|
+
? 'Hangi moda geçelim?'
|
|
107
|
+
: 'Bu taskta nasıl ilerleyelim?'
|
|
108
|
+
: input.expectedVersion
|
|
109
|
+
? 'Which mode should we switch to?'
|
|
110
|
+
: 'How should we work on this task?',
|
|
111
|
+
context: tr
|
|
112
|
+
? 'Seçim bu task için geçerli; istediğinde değiştirebilirsin.'
|
|
113
|
+
: 'This choice applies to this task; you can change it anytime.',
|
|
114
|
+
example: tr
|
|
115
|
+
? 'Örneğin canlıya alma kararını ajana bırakabilir veya kendin onaylayabilirsin.'
|
|
116
|
+
: 'For example, you can delegate a release decision or approve it yourself.',
|
|
117
|
+
language: tr ? 'tr' : 'en',
|
|
118
|
+
options: tr
|
|
119
|
+
? [
|
|
120
|
+
{
|
|
121
|
+
id: 'approve_for_me',
|
|
122
|
+
label: 'Benim için onayla',
|
|
123
|
+
description: 'Sıradan kararları ben veririm; çok kritik olanları sana sorarım.',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'autonomous',
|
|
127
|
+
label: 'Otonom',
|
|
128
|
+
description: 'Seçenekleri senin hedeflerine göre değerlendirip kararları gerekçesiyle veririm.',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 'ask',
|
|
132
|
+
label: 'Onay iste',
|
|
133
|
+
description: 'Karar gereken her durumda sana sorarım.',
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
: [
|
|
137
|
+
{
|
|
138
|
+
id: 'approve_for_me',
|
|
139
|
+
label: 'Approve for me',
|
|
140
|
+
description: 'I decide routine matters and ask you about critical ones.',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'autonomous',
|
|
144
|
+
label: 'Autonomous',
|
|
145
|
+
description: 'I weigh the choices against your goals and record my reasoning.',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 'ask',
|
|
149
|
+
label: 'Ask for approval',
|
|
150
|
+
description: 'I ask you whenever a decision is needed.',
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
binding: {
|
|
154
|
+
namespace,
|
|
155
|
+
externalTaskId: input.externalTaskId,
|
|
156
|
+
expectedVersion: input.expectedVersion,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
return definition;
|
|
160
|
+
}
|
|
161
|
+
async decisionModeSelect(input) {
|
|
162
|
+
const record = await this.questionnaireResume(input);
|
|
163
|
+
const namespace = this.dependencies.client.namespace;
|
|
164
|
+
if (record.owner?.tool !== 'decision.mode' ||
|
|
165
|
+
record.owner.externalTaskId !== input.externalTaskId ||
|
|
166
|
+
record.binding?.namespace !== namespace ||
|
|
167
|
+
record.binding?.externalTaskId !== input.externalTaskId ||
|
|
168
|
+
record.binding?.expectedVersion !== input.expectedVersion ||
|
|
169
|
+
record.status !== 'answered' ||
|
|
170
|
+
!record.answerAvailable ||
|
|
171
|
+
record.answerSource?.kind === 'delegated_agent')
|
|
172
|
+
throw refuse('A mode can only be selected through its native task mode question.', 'decision.mode');
|
|
173
|
+
const mode = decisionModeSchema.parse(record.answer?.choice);
|
|
174
|
+
return this.dependencies.questionnaires.decisions.select(record.scope, namespace, input.externalTaskId, input.expectedVersion, mode, record.questionnaireId);
|
|
175
|
+
}
|
|
176
|
+
async questionnaireDecisionPolicy(record, answer) {
|
|
177
|
+
const externalTaskId = record.owner?.externalTaskId;
|
|
178
|
+
const state = externalTaskId
|
|
179
|
+
? await this.dependencies.questionnaires.decisions.read(record.scope, this.dependencies.client.namespace, externalTaskId)
|
|
180
|
+
: null;
|
|
181
|
+
let impact = record.owner?.tool === 'questionnaire.ask' ? (record.impact ?? 'critical') : 'critical';
|
|
182
|
+
if (record.owner?.tool === 'task.branch' && record.binding) {
|
|
183
|
+
if (!answer)
|
|
184
|
+
impact = 'routine';
|
|
185
|
+
else {
|
|
186
|
+
const selected = taskStartChoice({
|
|
187
|
+
...record,
|
|
188
|
+
status: 'answered',
|
|
189
|
+
answerAvailable: true,
|
|
190
|
+
...(record.questions
|
|
191
|
+
? { answers: answer }
|
|
192
|
+
: { answer: answer }),
|
|
193
|
+
});
|
|
194
|
+
if (selected?.status === 'deferred' ||
|
|
195
|
+
(selected?.status === 'start' && !selected.inPlace && !selected.keepCurrent))
|
|
196
|
+
impact = 'routine';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (record.questionnaireId.startsWith('rule-deviation-') ||
|
|
200
|
+
record.questionnaireId.startsWith('validation-waiver-'))
|
|
201
|
+
impact = 'critical';
|
|
202
|
+
const userOnly = record.owner?.tool === 'decision.mode' || !record.scope.projectId || !externalTaskId;
|
|
203
|
+
const delegated = !userOnly &&
|
|
204
|
+
!!state?.configured &&
|
|
205
|
+
(state.mode === 'autonomous' || (state.mode === 'approve_for_me' && impact === 'routine'));
|
|
206
|
+
return { delegated, impact, state, userOnly };
|
|
207
|
+
}
|
|
208
|
+
async assertDecisionCurrent(record) {
|
|
209
|
+
const source = record.answerSource;
|
|
210
|
+
if (source?.kind !== 'delegated_agent')
|
|
211
|
+
return;
|
|
212
|
+
const policy = await this.questionnaireDecisionPolicy(record, record.questions ? record.answers : record.answer);
|
|
213
|
+
if (!policy.delegated ||
|
|
214
|
+
policy.state?.version !== source.modeVersion ||
|
|
215
|
+
policy.state.mode !== source.mode ||
|
|
216
|
+
record.owner?.externalTaskId !== source.externalTaskId ||
|
|
217
|
+
source.namespace !== this.dependencies.client.namespace)
|
|
218
|
+
throw refuse('The mode changed after this delegated answer. Re-evaluate using a new questionnaireId, or increment the owning operation decisionAttempt. Do not execute this answer.', 'questionnaire.resume');
|
|
219
|
+
}
|
|
220
|
+
async questionnaireDecide(input) {
|
|
221
|
+
const scope = await this.questionnaireScope(input.repoRoot);
|
|
222
|
+
const record = await this.dependencies.questionnaires.get(scope, input.questionnaireId);
|
|
223
|
+
if (!record ||
|
|
224
|
+
record.requestKey !== input.requestKey ||
|
|
225
|
+
record.contentHash !== input.contentHash)
|
|
226
|
+
throw refuse('Read the exact pending questionnaire before deciding.', 'questionnaire.resume');
|
|
227
|
+
const externalTaskId = record.owner?.externalTaskId;
|
|
228
|
+
if (!externalTaskId)
|
|
229
|
+
throw refuse('This question has no task delegation scope.', 'questionnaire.resume');
|
|
230
|
+
return this.dependencies.questionnaires.decisions.locked(scope, this.dependencies.client.namespace, externalTaskId, async () => {
|
|
231
|
+
const answer = input.answer ?? input.answers;
|
|
232
|
+
if (!answer || Boolean(input.answer) === Boolean(input.answers))
|
|
233
|
+
throw refuse('Exactly one of answer or answers is required. Resume the original question.', 'questionnaire.resume');
|
|
234
|
+
const policy = await this.questionnaireDecisionPolicy(record, answer);
|
|
235
|
+
if (!policy.delegated || !policy.state || policy.state.version !== input.modeVersion)
|
|
236
|
+
throw refuse('The current mode requires a native answer for this decision, or its version changed. Resume the question.', 'questionnaire.resume');
|
|
237
|
+
await this.assertDecisionCurrent(record);
|
|
238
|
+
const reasoning = delegatedReasonSchema.parse({
|
|
239
|
+
reason: input.reason,
|
|
240
|
+
alternativesConsidered: input.alternativesConsidered,
|
|
241
|
+
userInterestReview: input.userInterestReview,
|
|
242
|
+
});
|
|
243
|
+
const resolved = await this.dependencies.questionnaires.accept(scope, record.questionnaireId, record.requestKey, answer, {
|
|
244
|
+
kind: 'delegated_agent',
|
|
245
|
+
externalTaskId,
|
|
246
|
+
mode: policy.state.mode,
|
|
247
|
+
modeVersion: policy.state.version,
|
|
248
|
+
namespace: this.dependencies.client.namespace,
|
|
249
|
+
impact: policy.impact,
|
|
250
|
+
...reasoning,
|
|
251
|
+
});
|
|
252
|
+
return {
|
|
253
|
+
...resolved,
|
|
254
|
+
retry: this.questionnaireRetry(resolved.record, input.repoRoot ?? process.cwd()),
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
}
|
|
60
258
|
async questionnaireAsk(input, previousDefinitions = [], owner) {
|
|
61
259
|
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
62
260
|
const { repoRoot: _repoRoot, preparation: _preparation, presentation: _presentation, ...definition } = input;
|
|
@@ -64,6 +262,7 @@ export class BridgeService {
|
|
|
64
262
|
? null
|
|
65
263
|
: await this.checkoutTask(scope.repoFingerprint, await this.dependencies.repositories.git.findRoot(input.repoRoot ?? process.cwd()));
|
|
66
264
|
const record = await this.dependencies.questionnaires.ask(scope, definition, previousDefinitions, owner ?? (askingTask ? { tool: 'questionnaire.ask', externalTaskId: askingTask } : undefined));
|
|
265
|
+
await this.assertDecisionCurrent(record);
|
|
67
266
|
return { ...record, language: record.language ?? (await this.language()) };
|
|
68
267
|
}
|
|
69
268
|
async checkoutTask(repoFingerprint, repoRoot) {
|
|
@@ -80,6 +279,7 @@ export class BridgeService {
|
|
|
80
279
|
const record = await this.dependencies.questionnaires.get(scope, input.questionnaireId);
|
|
81
280
|
if (!record)
|
|
82
281
|
throw refuse('No questionnaire exists for this account and repository binding.', 'session.entry');
|
|
282
|
+
await this.assertDecisionCurrent(record);
|
|
83
283
|
return { ...record, language: record.language ?? (await this.language()) };
|
|
84
284
|
}
|
|
85
285
|
async questionnaireWithdraw(input) {
|
|
@@ -96,7 +296,7 @@ export class BridgeService {
|
|
|
96
296
|
async acceptWithScope(scope, questionnaireId, requestKey, answer, answerSource) {
|
|
97
297
|
return this.dependencies.questionnaires.accept(scope, questionnaireId, requestKey, answer, answerSource);
|
|
98
298
|
}
|
|
99
|
-
|
|
299
|
+
questionnaireRetry(record, repoRoot, attemptOffset = 0) {
|
|
100
300
|
if (!record.owner || record.owner.tool === 'questionnaire.ask')
|
|
101
301
|
return undefined;
|
|
102
302
|
const attempt = (record.owner.decisionAttempt ?? 0) + attemptOffset;
|
|
@@ -121,7 +321,7 @@ export class BridgeService {
|
|
|
121
321
|
const resolved = await this.acceptWithScope(scope, input.questionnaireId, input.requestKey, (input.answer ?? input.answers), { kind: 'host_native_relay', hostTool: input.hostTool });
|
|
122
322
|
return {
|
|
123
323
|
...resolved,
|
|
124
|
-
retry: this.
|
|
324
|
+
retry: this.questionnaireRetry(resolved.record, input.repoRoot ?? process.cwd()),
|
|
125
325
|
};
|
|
126
326
|
}
|
|
127
327
|
async questionnaireScope(repoRoot, preparation = false) {
|
|
@@ -227,7 +427,7 @@ export class BridgeService {
|
|
|
227
427
|
.map(([externalTaskId, record]) => asJsonValue({
|
|
228
428
|
externalTaskId,
|
|
229
429
|
deferredAt: record.answeredAt ?? record.createdAt,
|
|
230
|
-
reconsider: this.
|
|
430
|
+
reconsider: this.questionnaireRetry(record, repoRoot, 1),
|
|
231
431
|
nextAction: `Starting ${externalTaskId} is on hold. Mention it only if the user brings that task up, and call reconsider only when they ask to start it.`,
|
|
232
432
|
}));
|
|
233
433
|
return { mine, other, deferred };
|
|
@@ -415,6 +615,10 @@ export class BridgeService {
|
|
|
415
615
|
await this.seedResumeSnapshot(pointer, data);
|
|
416
616
|
return asJsonValue({
|
|
417
617
|
...data,
|
|
618
|
+
decisionMode: await this.decisionModeStatus({
|
|
619
|
+
repoRoot: repository.repoRoot,
|
|
620
|
+
externalTaskId: input.externalTaskId,
|
|
621
|
+
}),
|
|
418
622
|
checkpoint: checkpointView(checkpoint),
|
|
419
623
|
repository: publicRepository(repository),
|
|
420
624
|
localJournal: journalSummary(await this.dependencies.journal.load(projectId, input.externalTaskId)),
|
|
@@ -774,6 +978,10 @@ export class BridgeService {
|
|
|
774
978
|
return asJsonValue({
|
|
775
979
|
backend: resumeBackendView(backend, backendFresh || !localJournal.projection),
|
|
776
980
|
requirements: backend.requirements ?? null,
|
|
981
|
+
decisionMode: await this.decisionModeStatus({
|
|
982
|
+
repoRoot: repository.repoRoot,
|
|
983
|
+
externalTaskId: taskSlug,
|
|
984
|
+
}),
|
|
777
985
|
...(contextRefreshRequired
|
|
778
986
|
? {
|
|
779
987
|
staleReason: backend.staleReason ?? null,
|
|
@@ -1250,6 +1458,8 @@ export class BridgeService {
|
|
|
1250
1458
|
const question = input.questionnaireId
|
|
1251
1459
|
? await this.dependencies.questionnaires.get(scope, input.questionnaireId)
|
|
1252
1460
|
: null;
|
|
1461
|
+
if (question)
|
|
1462
|
+
await this.assertDecisionCurrent(question);
|
|
1253
1463
|
const digest = sha256(stableStringify(input.choice));
|
|
1254
1464
|
if (question?.answer?.choice !== 'approve' ||
|
|
1255
1465
|
(question.binding?.choiceDigest !== digest &&
|
|
@@ -1341,6 +1551,8 @@ export class BridgeService {
|
|
|
1341
1551
|
const digest = sha256(stableStringify(confirmedInput));
|
|
1342
1552
|
const scope = await this.questionnaireScope(input.repoRoot);
|
|
1343
1553
|
const question = await this.dependencies.questionnaires.get(scope, questionnaireId);
|
|
1554
|
+
if (question)
|
|
1555
|
+
await this.assertDecisionCurrent(question);
|
|
1344
1556
|
if (questionnaireId !== 'sync-start-' + digest ||
|
|
1345
1557
|
question?.answer?.choice !== 'approve' ||
|
|
1346
1558
|
input.ref !== snapshot.commit)
|
|
@@ -1706,6 +1918,8 @@ export class BridgeService {
|
|
|
1706
1918
|
return rule;
|
|
1707
1919
|
const questionnaireId = ruleDeviationQuestionnaireId(input.taskId, path, rule);
|
|
1708
1920
|
const question = await this.dependencies.questionnaires.get(scope, questionnaireId);
|
|
1921
|
+
if (question)
|
|
1922
|
+
await this.assertDecisionCurrent(question);
|
|
1709
1923
|
const choice = question?.status === 'answered' ? question.answer?.choice : undefined;
|
|
1710
1924
|
if (choice === 'change')
|
|
1711
1925
|
throw refuse(`The user chose to change ${path} so it follows ${rule.resourceKey}. Change the code, then record task.self_review again with that rule as fixed.`, 'task.self_review');
|
|
@@ -1717,7 +1931,12 @@ export class BridgeService {
|
|
|
1717
1931
|
deviationApproval: {
|
|
1718
1932
|
questionnaireId,
|
|
1719
1933
|
answeredAt: question.answeredAt,
|
|
1720
|
-
hostTool: question.answerSource?.
|
|
1934
|
+
hostTool: question.answerSource?.kind === 'host_native_relay'
|
|
1935
|
+
? question.answerSource.hostTool
|
|
1936
|
+
: null,
|
|
1937
|
+
...(delegatedEvidence(question)
|
|
1938
|
+
? { delegatedDecision: delegatedEvidence(question) }
|
|
1939
|
+
: {}),
|
|
1721
1940
|
},
|
|
1722
1941
|
};
|
|
1723
1942
|
})),
|
|
@@ -2043,6 +2262,8 @@ export class BridgeService {
|
|
|
2043
2262
|
for (const waiver of waivers) {
|
|
2044
2263
|
const questionnaireId = validationWaiverId(input.taskId, waiver.validationId, paths, waiver.reason);
|
|
2045
2264
|
const question = await this.dependencies.questionnaires.get(scope, questionnaireId);
|
|
2265
|
+
if (question)
|
|
2266
|
+
await this.assertDecisionCurrent(question);
|
|
2046
2267
|
if (question?.answer?.choice !== 'waive') {
|
|
2047
2268
|
throw refuse(`The user has not agreed to skip the ${waiver.validationId} check for ${samplePaths(paths) || 'this change'} with this reason. Call task.verify with the waiver so the tool asks them, or provide the evidence.`, 'task.verify');
|
|
2048
2269
|
}
|
|
@@ -2051,7 +2272,14 @@ export class BridgeService {
|
|
|
2051
2272
|
questionnaireId,
|
|
2052
2273
|
reason: waiver.reason,
|
|
2053
2274
|
paths,
|
|
2054
|
-
answerSource: question.answerSource?.
|
|
2275
|
+
answerSource: question.answerSource?.kind === 'delegated_agent'
|
|
2276
|
+
? 'delegated_agent'
|
|
2277
|
+
: question.answerSource?.kind === 'host_native_relay'
|
|
2278
|
+
? question.answerSource.hostTool
|
|
2279
|
+
: 'mcp_form',
|
|
2280
|
+
...(delegatedEvidence(question)
|
|
2281
|
+
? { delegatedDecision: delegatedEvidence(question) }
|
|
2282
|
+
: {}),
|
|
2055
2283
|
});
|
|
2056
2284
|
}
|
|
2057
2285
|
}
|
|
@@ -3077,6 +3305,19 @@ export class BridgeService {
|
|
|
3077
3305
|
});
|
|
3078
3306
|
});
|
|
3079
3307
|
}
|
|
3308
|
+
async workItemStatuses(input) {
|
|
3309
|
+
return this.execute(async () => {
|
|
3310
|
+
const query = new URLSearchParams({
|
|
3311
|
+
offset: String(input.offset ?? 0),
|
|
3312
|
+
limit: String(input.limit ?? 50),
|
|
3313
|
+
});
|
|
3314
|
+
if (input.includeArchived !== undefined) {
|
|
3315
|
+
query.set('includeArchived', String(input.includeArchived));
|
|
3316
|
+
}
|
|
3317
|
+
const response = await this.dependencies.client.request(`${endpoints.workItemStatuses(input.projectId)}?${query.toString()}`);
|
|
3318
|
+
return asJsonValue(response.data);
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3080
3321
|
async workItemList(input) {
|
|
3081
3322
|
return await this.execute(async () => {
|
|
3082
3323
|
const query = new URLSearchParams();
|
|
@@ -3089,6 +3330,10 @@ export class BridgeService {
|
|
|
3089
3330
|
}
|
|
3090
3331
|
if (input.includeArchived)
|
|
3091
3332
|
query.set('includeArchived', 'true');
|
|
3333
|
+
if (input.offset !== undefined)
|
|
3334
|
+
query.set('offset', String(input.offset));
|
|
3335
|
+
if (input.limit !== undefined)
|
|
3336
|
+
query.set('limit', String(input.limit));
|
|
3092
3337
|
const suffix = query.size > 0 ? `?${query.toString()}` : '';
|
|
3093
3338
|
const response = await this.dependencies.client.request(`${endpoints.workItemList(input.projectId)}${suffix}`);
|
|
3094
3339
|
return asJsonValue({
|
|
@@ -3551,30 +3796,15 @@ export class BridgeService {
|
|
|
3551
3796
|
}
|
|
3552
3797
|
}
|
|
3553
3798
|
async actionableWorkItems(projectId) {
|
|
3554
|
-
const path = `${endpoints.workItemList(projectId)}?limit=100`;
|
|
3555
3799
|
try {
|
|
3556
|
-
const
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
throw error;
|
|
3560
|
-
});
|
|
3561
|
-
if (chosen && chosen.length > 0)
|
|
3562
|
-
return chosen;
|
|
3563
|
-
const every = await this.workItemPage(path);
|
|
3564
|
-
if (chosen && every.some((entry) => objectValue(objectValue(entry)?.projectStatus) !== null))
|
|
3565
|
-
return [];
|
|
3566
|
-
const actionable = new Set(['backlog', 'ready', 'in_progress', 'in_review']);
|
|
3567
|
-
return every.filter((entry) => actionable.has(String(objectValue(entry)?.status)));
|
|
3800
|
+
const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100&actionable=true`);
|
|
3801
|
+
const items = objectValue(response.data)?.items;
|
|
3802
|
+
return Array.isArray(items) ? items : [];
|
|
3568
3803
|
}
|
|
3569
3804
|
catch {
|
|
3570
3805
|
return [];
|
|
3571
3806
|
}
|
|
3572
3807
|
}
|
|
3573
|
-
async workItemPage(path) {
|
|
3574
|
-
const response = await this.dependencies.client.request(path);
|
|
3575
|
-
const items = objectValue(response.data)?.items;
|
|
3576
|
-
return Array.isArray(items) ? items : [];
|
|
3577
|
-
}
|
|
3578
3808
|
async clientUpdate(authenticated) {
|
|
3579
3809
|
const installed = this.dependencies.clientVersion;
|
|
3580
3810
|
if (!authenticated) {
|
|
@@ -5418,4 +5648,16 @@ function publicMembership(value) {
|
|
|
5418
5648
|
}
|
|
5419
5649
|
return result;
|
|
5420
5650
|
}
|
|
5651
|
+
function delegatedEvidence(question) {
|
|
5652
|
+
const source = question.answerSource;
|
|
5653
|
+
if (source?.kind !== 'delegated_agent')
|
|
5654
|
+
return undefined;
|
|
5655
|
+
return {
|
|
5656
|
+
mode: source.mode,
|
|
5657
|
+
modeVersion: source.modeVersion,
|
|
5658
|
+
reason: source.reason,
|
|
5659
|
+
alternativesConsidered: source.alternativesConsidered,
|
|
5660
|
+
userInterestReview: source.userInterestReview,
|
|
5661
|
+
};
|
|
5662
|
+
}
|
|
5421
5663
|
//# sourceMappingURL=bridge-service.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import * as z from 'zod/v4';
|
|
3
|
+
import { readJson, writeJson } from '../utilities/files.js';
|
|
4
|
+
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
5
|
+
import { localMutex } from '../utilities/local-mutex.js';
|
|
6
|
+
export const decisionModeSchema = z.enum(['autonomous', 'approve_for_me', 'ask']);
|
|
7
|
+
export const decisionTaskId = z
|
|
8
|
+
.string()
|
|
9
|
+
.min(2)
|
|
10
|
+
.max(160)
|
|
11
|
+
.regex(/^[^\r\n]+$/);
|
|
12
|
+
const selectionSchema = z.strictObject({
|
|
13
|
+
mode: decisionModeSchema,
|
|
14
|
+
version: z.number().int().positive(),
|
|
15
|
+
questionnaireId: z.string().min(1).max(100),
|
|
16
|
+
changedAt: z.string().datetime(),
|
|
17
|
+
});
|
|
18
|
+
const stateSchema = z.strictObject({
|
|
19
|
+
scopeKey: z.string().regex(/^[0-9a-f]{64}$/),
|
|
20
|
+
externalTaskId: decisionTaskId,
|
|
21
|
+
selections: z.array(selectionSchema).min(1),
|
|
22
|
+
});
|
|
23
|
+
export class DecisionModeStore {
|
|
24
|
+
root;
|
|
25
|
+
constructor(stateRoot) {
|
|
26
|
+
this.root = join(stateRoot, 'decision-modes');
|
|
27
|
+
}
|
|
28
|
+
identity(scope, namespace, externalTaskId) {
|
|
29
|
+
decisionTaskId.parse(externalTaskId);
|
|
30
|
+
return sha256(stableStringify({ scope, namespace, externalTaskId }));
|
|
31
|
+
}
|
|
32
|
+
path(scope, namespace, externalTaskId) {
|
|
33
|
+
return join(this.root, this.identity(scope, namespace, externalTaskId) + '.json');
|
|
34
|
+
}
|
|
35
|
+
async read(scope, namespace, externalTaskId) {
|
|
36
|
+
const history = await this.history(scope, namespace, externalTaskId);
|
|
37
|
+
const last = history.at(-1);
|
|
38
|
+
return last
|
|
39
|
+
? {
|
|
40
|
+
externalTaskId,
|
|
41
|
+
mode: last.mode,
|
|
42
|
+
version: last.version,
|
|
43
|
+
configured: true,
|
|
44
|
+
questionnaireId: last.questionnaireId,
|
|
45
|
+
}
|
|
46
|
+
: { externalTaskId, mode: 'ask', version: 0, configured: false };
|
|
47
|
+
}
|
|
48
|
+
async history(scope, namespace, externalTaskId) {
|
|
49
|
+
const key = this.identity(scope, namespace, externalTaskId);
|
|
50
|
+
const raw = await readJson(this.path(scope, namespace, externalTaskId), this.root);
|
|
51
|
+
if (!raw)
|
|
52
|
+
return [];
|
|
53
|
+
const state = stateSchema.parse(raw);
|
|
54
|
+
if (state.scopeKey !== key ||
|
|
55
|
+
state.externalTaskId !== externalTaskId ||
|
|
56
|
+
state.selections.some((entry, index) => entry.version !== index + 1))
|
|
57
|
+
throw new Error('Decision mode state does not match this task.');
|
|
58
|
+
return state.selections;
|
|
59
|
+
}
|
|
60
|
+
async locked(scope, namespace, externalTaskId, work) {
|
|
61
|
+
return localMutex(this.path(scope, namespace, externalTaskId), work);
|
|
62
|
+
}
|
|
63
|
+
async select(scope, namespace, externalTaskId, expectedVersion, mode, questionnaireId) {
|
|
64
|
+
return this.locked(scope, namespace, externalTaskId, async () => {
|
|
65
|
+
const selections = await this.history(scope, namespace, externalTaskId);
|
|
66
|
+
const current = await this.read(scope, namespace, externalTaskId);
|
|
67
|
+
if (current.version === expectedVersion + 1 &&
|
|
68
|
+
current.questionnaireId === questionnaireId &&
|
|
69
|
+
current.mode === mode)
|
|
70
|
+
return current;
|
|
71
|
+
if (current.version !== expectedVersion)
|
|
72
|
+
throw new Error('The decision mode changed. Read decision.mode_status and open a new mode question.');
|
|
73
|
+
selections.push(selectionSchema.parse({
|
|
74
|
+
mode,
|
|
75
|
+
version: expectedVersion + 1,
|
|
76
|
+
questionnaireId,
|
|
77
|
+
changedAt: new Date().toISOString(),
|
|
78
|
+
}));
|
|
79
|
+
await writeJson(this.path(scope, namespace, externalTaskId), {
|
|
80
|
+
scopeKey: this.identity(scope, namespace, externalTaskId),
|
|
81
|
+
externalTaskId,
|
|
82
|
+
selections,
|
|
83
|
+
}, this.root);
|
|
84
|
+
return this.read(scope, namespace, externalTaskId);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=decision-mode-store.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { DecisionModeStore, decisionModeSchema } from './decision-mode-store.js';
|
|
2
3
|
import { link, readdir } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import * as z from 'zod/v4';
|
|
@@ -76,6 +77,7 @@ function attachDefinitionChecks(schema) {
|
|
|
76
77
|
}
|
|
77
78
|
const questionnaireDefinitionShape = z.strictObject({
|
|
78
79
|
questionnaireId: identifier,
|
|
80
|
+
impact: z.enum(['routine', 'critical']).optional(),
|
|
79
81
|
message: z.string().trim().min(1).max(2000),
|
|
80
82
|
context: z
|
|
81
83
|
.string()
|
|
@@ -149,10 +151,25 @@ export const hostAnswerSchema = z
|
|
|
149
151
|
.optional(),
|
|
150
152
|
})
|
|
151
153
|
.refine((value) => Boolean(value.answer) !== Boolean(value.answers), 'Exactly one of answer or answers must be set.');
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
export const delegatedReasonSchema = z.strictObject({
|
|
155
|
+
reason: z.string().trim().min(20).max(1200),
|
|
156
|
+
alternativesConsidered: z.string().trim().min(20).max(1200),
|
|
157
|
+
userInterestReview: z.string().trim().min(20).max(1200),
|
|
155
158
|
});
|
|
159
|
+
const answerSourceSchema = z.union([
|
|
160
|
+
z.strictObject({
|
|
161
|
+
kind: z.literal('host_native_relay'),
|
|
162
|
+
hostTool: z.enum(['AskUserQuestion', 'request_user_input']),
|
|
163
|
+
}),
|
|
164
|
+
delegatedReasonSchema.extend({
|
|
165
|
+
kind: z.literal('delegated_agent'),
|
|
166
|
+
externalTaskId: z.string().min(2).max(160),
|
|
167
|
+
mode: decisionModeSchema,
|
|
168
|
+
modeVersion: z.number().int().positive(),
|
|
169
|
+
namespace: z.string().regex(/^[0-9a-f]{64}$/),
|
|
170
|
+
impact: z.enum(['routine', 'critical']),
|
|
171
|
+
}),
|
|
172
|
+
]);
|
|
156
173
|
const storedAnswerSchema = z.strictObject({
|
|
157
174
|
requestKey: z.string().regex(/^questionnaire_[0-9a-f]{64}$/),
|
|
158
175
|
answeredAt: z.string().datetime(),
|
|
@@ -237,8 +254,10 @@ export function questionnaireAnswerSchema(record) {
|
|
|
237
254
|
}
|
|
238
255
|
export class QuestionnaireStore {
|
|
239
256
|
root;
|
|
257
|
+
decisions;
|
|
240
258
|
constructor(stateRoot) {
|
|
241
259
|
this.root = join(stateRoot, 'questionnaires');
|
|
260
|
+
this.decisions = new DecisionModeStore(stateRoot);
|
|
242
261
|
}
|
|
243
262
|
async ask(scope, input, previousDefinitions = [], owner) {
|
|
244
263
|
const definition = boundDefinitionSchema.parse(input);
|
|
@@ -268,6 +287,10 @@ export class QuestionnaireStore {
|
|
|
268
287
|
(record.contentHash !== contentHash && !previousHashes.includes(record.contentHash))) {
|
|
269
288
|
throw new Error('Questionnaire id was reused with different content. Resume the original question or use a new id.');
|
|
270
289
|
}
|
|
290
|
+
if (owner &&
|
|
291
|
+
record.owner &&
|
|
292
|
+
(owner.tool !== record.owner.tool || owner.externalTaskId !== record.owner.externalTaskId))
|
|
293
|
+
throw new Error('This questionnaire belongs to another task or operation. Resume it in its owning task.');
|
|
271
294
|
return record;
|
|
272
295
|
}
|
|
273
296
|
async get(scope, questionnaireId) {
|
|
@@ -277,6 +300,7 @@ export class QuestionnaireStore {
|
|
|
277
300
|
const question = storedQuestionSchema.parse(raw);
|
|
278
301
|
const definition = boundDefinitionSchema.parse({
|
|
279
302
|
questionnaireId: question.questionnaireId,
|
|
303
|
+
impact: question.impact,
|
|
280
304
|
message: question.message,
|
|
281
305
|
context: question.context,
|
|
282
306
|
example: question.example,
|
package/skill/SKILL.md
CHANGED
|
@@ -11,11 +11,13 @@ Read [lifecycle.md](references/lifecycle.md) before acting in a bound repository
|
|
|
11
11
|
|
|
12
12
|
Mandatory behavior:
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Every new task starts with a short native mode selector through task.branch or session.bootstrap: Autonomous, Approve for me (recommended), or Ask for approval. Resume and compaction retain that task's selection. When the user wants to change mode, read decision.mode_status and open decision.mode at its current version. This selector always needs a real native user answer. Autonomous records reasoned agent decisions; Approve for me delegates routine decisions and asks critical ones; Ask for approval asks every required decision. Follow the skill's questionnaires reference and use questionnaire.decide for delegated_decision_required, comparing alternatives and reviewing the user's interests. Never blindly choose the recommendation or relay agent decisions as native answers. Releases, irreversible changes, access, spending, scope expansion and rule exceptions are critical. A mode never overrides host permissions or the normal verification gates.
|
|
15
|
+
|
|
16
|
+
When the task mode requires a user answer, use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. For a required decision, read [questionnaires.md](references/questionnaires.md), then use `questionnaire.ask` to open a durable native MCP form and `questionnaire.resume` to return to the same unanswered question. Never use request_user_input_async for a required decision: it does not wait for an answer. When the durable form is unavailable, use a blocking native control only where the host permits it: request_user_input in Codex or AskUserQuestion in Claude. Never replace the questionnaire with a chat instruction such as 'type this', 'reply yes', or 'write X if you want Y'. Do not open a survey web page. If the required native control is unavailable or prohibited for that kind of question, follow the host's tool restrictions, explain the limitation, and continue only work already authorized; do not fabricate a survey or silently choose an answer. A timeout, dismissed form, empty response or ended turn is not an answer. Keep the decision pending and resume it; do not start dependent work or report it as resolved. Existing answers remain valid through retries and handoffs.
|
|
15
17
|
|
|
16
18
|
When a durable MCP form cannot be displayed, read its `hostFallback` or call `questionnaire.resume` with `presentation: host_native` to get the original question without reopening the MCP form. Display the same question, all choices and notices through a blocking native control only if the host permits that control for this decision. After an actual native answer, call `questionnaire.answer_from_host` with the unchanged questionnaireId, requestKey and contentHash, the hostTool name and the returned choice/text. This is an agent-reported relay, not MCP transport attestation. Then retry the owning operation; its authority, content and version checks still apply. Never relay prose consent, a default, an asynchronous response or a cancelled/declined/missing answer. Decline alone is not proof that the host cannot display forms. Keep the decision pending if no permitted native control can represent it.
|
|
17
19
|
|
|
18
|
-
Do not block independent task work on `memory.propose_revision` drafting, submission or approval. Follow [memory-updates.md](references/memory-updates.md): use background agents or concurrent tools when the host permits them and continue useful work; without concurrency, checkpoint the pending draft and defer submission until needed. Wait only at the operation that depends on the proposal or revision. This scheduling rule applies to every project and AI host, while
|
|
20
|
+
Do not block independent task work on `memory.propose_revision` drafting, submission or approval. Follow [memory-updates.md](references/memory-updates.md): use background agents or concurrent tools when the host permits them and continue useful work; without concurrency, checkpoint the pending draft and defer submission until needed. Wait only at the operation that depends on the proposal or revision. This scheduling rule applies to every project and AI host, while the selected mode's decision receipt and required task verification remain in force. Apply the same dependency rule to reconciliation, self-review, scaffold receipts, inventory upload, completed-unit evidence and test-result reporting. A bridge result with deliveryStatus pending means durable local recording, so continue independent work without polling or resending. Keep claim acquisition, required approval, edit leases and final verification as real dependencies.
|
|
19
21
|
|
|
20
22
|
1. Discover the repository binding through `session.entry`. Bindings live in the user-level Engineering Memory state directory, outside the repository and installed runtime. No project settings file is required. For a bound repository, the project's knowledge is in the backend, so answer nothing about it before bootstrapping; the absence of local design files or records says nothing about its stored knowledge.
|
|
21
23
|
2. Call `session.entry` before answering anything in a repository, and act on what it reports before the message itself: sign in when it says so, ask for organization and project when nothing has been decided, and stay completely silent about Engineering Memory in a repository where the user switched it off. Record every one of those answers with `session.set_decision`, and only ever from something the user actually said.
|
|
@@ -28,7 +30,7 @@ Do not block independent task work on `memory.propose_revision` drafting, submis
|
|
|
28
30
|
9. Before validation, read each changed file back against the rules `context.prepare_change` returned for it in `governingRules`, and record `task.self_review` with one entry per changed file naming each of those rules as `follows`, `fixed` or `user_accepted_deviation`. A deviation counts only after the user approves it in the rule-deviation question the bridge asks; matching existing code is not an outcome. Verification refuses without a review of the current diff, and any later edit requires reviewing again.
|
|
29
31
|
10. Run `task.verify` before claiming completion. Write tasks verify the exact Git diff, active lease, and structured command-bound validation evidence. Read-only tasks verify that the current Git diff hash still equals the baseline captured at bootstrap, plus the required discovery, validation, handoff, pinned-context, and synchronization evidence. Run `task.close` only after verification succeeds. Where the pre-commit hook was installed, it confirms the closed task online; a local verification receipt is insufficient. Without the hook nothing checks the commit, so never claim a hook confirmed closure.
|
|
30
32
|
11. After closing a task, settle the delivery question — commit, commit and push, or either of those with a draft or ready pull request onto a base branch the user names — naming the branch, remote and URL the close returned, and do only what they choose. A choice the user already made in their own message is the answer; do not ask it again. When the host's reviewer denies a push or pull request they chose, never use an Engineering Memory form or a typed phrase to get past it; follow the Delivery section of questionnaires.md. Once a pull request exists, check whether it merges cleanly and ask before resolving a conflict.
|
|
31
|
-
12. Never commit, push, publish, deploy, approve a permanent memory revision, or overwrite an existing Git hook without explicit user authorization.
|
|
33
|
+
12. Never commit, push, publish, deploy, approve a permanent memory revision, or overwrite an existing Git hook without explicit user authorization or a valid decision delegated by the selected task mode. Host approval controls still apply.
|
|
32
34
|
13. Never store tokens, passwords, client secrets, raw headers, raw payloads, customer data, or PII in tool inputs, journals, memory, logs, or generated documentation.
|
|
33
35
|
|
|
34
36
|
If the repository is unbound, do not silently create or attach a project. Use the native questionnaire workflow. If the user selects task-only skip, do not create a binding or memory records.
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Mandatory Lifecycle
|
|
2
2
|
|
|
3
|
+
## Decision mode at task start
|
|
4
|
+
|
|
5
|
+
Every new task starts with a short native mode selector through task.branch or session.bootstrap: Autonomous, Approve for me (recommended), or Ask for approval. Resume and compaction retain that task's selection. When the user wants to change mode, read decision.mode_status and open decision.mode at its current version. This selector always needs a real native user answer. Autonomous records reasoned agent decisions; Approve for me delegates routine decisions and asks critical ones; Ask for approval asks every required decision. Follow the skill's questionnaires reference and use questionnaire.decide for delegated_decision_required, comparing alternatives and reviewing the user's interests. Never blindly choose the recommendation or relay agent decisions as native answers. Releases, irreversible changes, access, spending, scope expansion and rule exceptions are critical. A mode never overrides host permissions or the normal verification gates.
|
|
6
|
+
|
|
3
7
|
## Entry
|
|
4
8
|
|
|
5
9
|
Sign-in decides nothing beyond who the user is. The organization and the project are chosen after it, through the questionnaires in `questionnaires.md`, and both listings end with an option to create a new one. Ask for both whenever this session has not already confirmed them, and ask again the moment the user says they want to change either — changing the organization always means choosing the project again.
|
|
@@ -20,7 +24,7 @@ For a bound repository, call `session.bootstrap` before producing a plan or chan
|
|
|
20
24
|
|
|
21
25
|
Before a new write or scaffold task, read `session.entry` and the project Git preferences. If `canManage` is true, and any of development/production/test is still unanswered, ask them through one native form and save them with `project.set_git_preferences` — the tool asks every supplied role as its own question in that single form, then saves the approved roles together in one PUT. Development needs a branch; production/test may explicitly be absent. Their answered flags prevent repeated questions. Other members choose only a task-specific base and continue without changing shared preferences. A later request to change a base updates future tasks only. Branch preferences never label memory sources or change commit/tree applicability.
|
|
22
26
|
|
|
23
|
-
Start new write or scaffold work with `task.branch`, before `session.bootstrap`, passing the stable `externalTaskId` and `repoRoot`. It opens
|
|
27
|
+
Start new write or scaffold work with `task.branch`, before `session.bootstrap`, passing the stable `externalTaskId` and `repoRoot`. It first opens the three-choice task mode selector. The subsequent start decision asks two short questions, the starting branch and the folder, through a native form or delegated reasoning according to that mode. The branch name is generated automatically; a user-requested name may be supplied through `name`. Identical configured remote branches appear once. Folder choices: a separate managed worktree, this folder as a new branch when it is clean (moving out an unfinished task that holds it, which that option names), this folder on its current branch, or not now. Pass `base` or `name` when the user has already specified them; a supplied base removes that question. Do not ask a separate naming question. Keep the form concise and do not repeat its choices in a long chat introduction. Show the whole form in as few native control calls as the host allows, relay every answer together, then call `task.branch` again with the same arguments, which is the `retry` the relay returns. That call allocates exactly what the native or mode-delegated answer selected, fetches a remote base and pins its commit, and returns the `repoRoot` to bootstrap in. When the fetch fails it asks once whether to continue from the local commit; never select a cached branch silently. "Not now" returns `status: 'deferred'` with nothing created, reserved or saved: say so in one line, continue only read-only work, and call the returned `reconsider` exactly as given once the user asks to start it. A refusal that names a new `decisionAttempt`, such as a branch name that already exists, asks the form again under that attempt. Existing task decisions survive retries and restarts; resume a recorded branch rather than recreating or resetting it.
|
|
24
28
|
|
|
25
29
|
Use the returned `repoRoot` for **every** file read/write, terminal, context, validation and Git/delivery operation. The user-local pool is shared by Codex and Claude. Managed directories live under `engineering_memory/worktrees/<stable-project-folder>/<folder>_worktreeN`, at the root of the system drive on Windows (`C:\engineering_memory\worktrees\...`) and in the home directory elsewhere, or under the absolute directory named by `worktree-root.json` in the user-level API state directory when that file exists; `worktree.list` reports the effective root, and worktrees created by earlier clients under the Documents folder keep working where they are. Do not supply arbitrary `worktreePath` values or create ad-hoc siblings. Independent clones cannot reuse each other's worktrees. `worktree.list` explains which slots are active, inactive but protected, or safely reusable. The versioned backend policy defaults to 50 directories per project/computer, 30-second heartbeats and 10-minute inactivity. Protected inactive directories still count toward the limit. Only a global admin changes `worktree.set_policy`; it is not an environment setting.
|
|
26
30
|
|
|
@@ -40,7 +44,7 @@ A task nobody is going to finish is abandoned rather than inherited. Ask the use
|
|
|
40
44
|
|
|
41
45
|
If an existing task is identified or execution resumes after compaction, call `session.resume`. Reconcile backend sequence, local outbox, Markdown projections, current Git diff, pinned revisions, and the active lease before any further action.
|
|
42
46
|
|
|
43
|
-
Reconcile pending questions too. A required question remains unanswered across a timeout, dismissed form, interruption or restart. Use `questionnaire.resume` for its recorded identifier and
|
|
47
|
+
Reconcile pending questions too. A required question remains unanswered across a timeout, dismissed form, interruption or restart. Use `questionnaire.resume` for its recorded identifier and follow the current mode for an explicit valid native response or reasoned delegated decision before dependent work. Follow `questionnaires.md`; do not create a replacement async prompt or consume an unrelated task's answer. A question belongs to the task the chat was working on when it was asked. `pendingQuestionnaires` lists this chat's own questions and those that belong to no task in full; another task's appear under `otherTasksPendingQuestionnaires` as a one-line summary naming that task. Resume one of those only when this chat is working on the task it names.
|
|
44
48
|
|
|
45
49
|
## Discovery
|
|
46
50
|
|
|
@@ -73,6 +77,14 @@ One WorkItem UUID coordinates independent EngineeringTask runs, one per selected
|
|
|
73
77
|
|
|
74
78
|
Open each side with the same workItemId and its own repository binding when work reaches that side. Each side has its own lease, reconciliation, verify, close and commit gate. Check `work_item.runs` and sibling checkpoints when handing off; one side completing does not prove all selected projects are complete. A plan's scope and branch cannot change while an implementation run is active. Read-only analysis does not acquire application write rights; its later write transition must satisfy discipline and the confirmed plan.
|
|
75
79
|
|
|
80
|
+
### Work item statuses
|
|
81
|
+
|
|
82
|
+
Read `work_item.statuses` before choosing a status for `work_item.update` or filtering `work_item.list`. The catalogue belongs to the project: follow its user-defined `meaning`, `entryRule` and flags. Never infer workflow from a name or translate statuses into the old six-value set. Send the actual catalogue slug. A work item's `status` is that slug, or null when its status link is invalid.
|
|
83
|
+
|
|
84
|
+
The catalogue returns `items`, `total`, `offset` and `limit`. Page with `offset` (default 0) and `limit` (default 50, maximum 100); use `includeArchived: true` to inspect retained archived statuses. Each row includes `id`, `slug`, `name`, `category`, `isInitial`, `isActionable`, `isParked`, `isTerminal`, `archivedAt`, `meaning`, `entryRule`, `position` and `lockVersion`. The work item's nested `projectStatus` remains its compact nine-field summary. `work_item.list` also accepts `offset` and `limit`; a status filter matches the actual slug, including retained archived statuses, and an unknown slug returns an empty page. Its `includeArchived` option concerns work items.
|
|
85
|
+
|
|
86
|
+
Session entry uses the backend's actionable selection. An empty page remains empty, and a failed lookup offers no work items; never retry an unfiltered list to classify statuses locally. Reuse existing user choices. Any unresolved workflow choice must use the native questionnaire.
|
|
87
|
+
|
|
76
88
|
### Administration, product management and QA
|
|
77
89
|
|
|
78
90
|
Administrative authority and work discipline are independent. Organization admins can manage projects, assignments, repository URLs and work items in their organization. A global admin has those rights only in organizations they actively belong to. Ordinary users need a live explicit project assignment. Active organization administrators, including a global admin with live membership in that organization, inherit contributor access to its projects without a separate project grant. Fullstack/backend/web/mobile/frontend disciplines still govern implementation, while project overrides do not grant administration.
|
|
@@ -308,7 +320,7 @@ inspection and answer. This recovery never deletes files or branches or interrup
|
|
|
308
320
|
When a recorded checkout is gone or can no longer be read as a Git worktree — its `.git` was
|
|
309
321
|
deleted, or the repository it was linked to was removed — `worktree.list` names `worktree.reconcile`
|
|
310
322
|
with `forgetUnreadable` and that task's `externalTaskId`, called from a working checkout of the same
|
|
311
|
-
project. After its own
|
|
323
|
+
project. After its own mode-governed approval Engineering Memory only stops tracking that entry, so it
|
|
312
324
|
stops counting toward the limit; no file in the folder is moved or deleted, the branch and the task
|
|
313
325
|
stay, and a readable checkout is still freed with `worktree.release`.
|
|
314
326
|
|
|
@@ -4,19 +4,23 @@ Backend resources are immutable revisions. The local agent drafts structured con
|
|
|
4
4
|
|
|
5
5
|
Use `memory.propose_revision` for project profiles, engineering rules, service contracts, localization contracts, navigation contracts, state contracts, screen logic, component mappings, Figma mappings, current deviations, quality gates, task history, and architecture templates.
|
|
6
6
|
|
|
7
|
+
## Decision authority
|
|
8
|
+
|
|
9
|
+
Apply the current task decision mode from the questionnaires reference to proposal decisions too. Autonomous reviews the exact content and records a reasoned delegated choice; Approve for me treats permanent shared memory changes as critical and asks; Ask for approval presents the native question. Neither a worker's report nor a recommendation is an approval. Do not claim an agent decision was a human form response. Existing membership, source, version and verification checks still govern activation.
|
|
10
|
+
|
|
7
11
|
## Continue independent work while proposals progress
|
|
8
12
|
|
|
9
13
|
Do not block independent task work on proposal drafting, submission or approval. This is the usage contract for every project, user and AI host, including Codex and Claude. Decide whether the next operation actually needs the proposal ID or its approved revision; the existence of a pending proposal is not itself a reason to wait.
|
|
10
14
|
|
|
11
15
|
When the host permits background agents or concurrent tool calls, delegate a bounded set of independent proposals and immediately continue useful work. Collect that worker's result only at a real dependency, rather than spawning a worker and immediately waiting for it. Keep one writer per resource; proposals for different resources may progress together. Keep task-version-changing lifecycle mutations ordered under the owning agent, and handle a resulting version conflict through the normal recovery instead of weakening version checks.
|
|
12
16
|
|
|
13
|
-
Pass the worker the exact project, task/session and source-run identity that applies, resource identity, baseRevision, verified evidence, proposed selectors and scope. Reuse the owning task's context; delegation neither opens another engineering task nor grants application-edit, approval or delivery authority. Do not pass credentials or claim tokens in prompts or saved documents. The main agent owns native questionnaires and may review or activate a proposal only after the user's explicit decision.
|
|
17
|
+
Pass the worker the exact project, task/session and source-run identity that applies, resource identity, baseRevision, verified evidence, proposed selectors and scope. Reuse the owning task's context; delegation neither opens another engineering task nor grants application-edit, approval or delivery authority. Do not pass credentials or claim tokens in prompts or saved documents. The main agent owns native questionnaires and may review or activate a proposal only after the user's explicit decision or a valid, recorded decision delegated by the current task mode.
|
|
14
18
|
|
|
15
19
|
If background execution is unavailable or prohibited, preserve the pending draft and its evidence through `task.checkpoint` documents and continue the independent work first. Submit the draft when an operation needs its result. Never claim a background worker exists on a serial-only host or skip the proposal permanently. An approved architectural or flow change that implementation depends on is still a prerequisite for that implementation.
|
|
16
20
|
|
|
17
21
|
Track each draft, in-flight call, returned proposal ID, queued delivery and failure in the task's checkpoint documents, with its resource/base revision and the bridge-returned delivery identifiers when available. The MCP call still waits for its own real response: `queued: false` reports a stored inactive proposal, while `queued: true` reports a durable outbox entry, not an approved revision. Reuse the existing outbox delivery and its idempotency key through the supported recovery; do not invoke a fresh proposal call merely to retry an already queued one. If the response is lost, use `memory.list_proposals` to check for the exact proposed content before resubmitting. Compare a changed base revision rather than overwriting it.
|
|
18
22
|
|
|
19
|
-
For example, while a worker records a finished screen's contract, the main agent can inspect an unrelated service or implement an already-approved component. If the next screen needs a changed flow contract, wait for that specific contract's
|
|
23
|
+
For example, while a worker records a finished screen's contract, the main agent can inspect an unrelated service or implement an already-approved component. If the next screen needs a changed flow contract, wait for that specific contract's mode-governed approval before implementing it. During adoption or refresh, pending proposals for one unit do not stop inspection of independent units. Required proposals, approvals and reconciliations must still be settled before claiming their dependent unit or the whole inspection complete. Unrelated proposal approval never becomes a prerequisite for another task.
|
|
20
24
|
|
|
21
25
|
Before a dependent reconciliation, verification or handoff, collect the relevant results and report pending or failed work accurately. Do not close or describe unfinished required memory work as complete. A dismissed questionnaire, timeout or deferred submission is not consent.
|
|
22
26
|
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# Native Questionnaires
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Task decision modes
|
|
4
|
+
|
|
5
|
+
Every new task first opens one short native selector: **Approve for me** (recommended), **Autonomous**, or **Ask for approval**. task.branch does this before its branch/folder decision; session.bootstrap does it for a new read-only task. Do not ask a second custom form. A resumed task keeps its selection; compaction, restart and a write transition are not new tasks.
|
|
6
|
+
|
|
7
|
+
- **Autonomous**: consider the choices against the user's goals, compare alternatives, independently review the user's interests, and record a reasoned decision. Never blindly select the first or recommended option.
|
|
8
|
+
- **Approve for me**: do the same for routine decisions; ask the user about critical ones.
|
|
9
|
+
- **Ask for approval**: present every required decision natively.
|
|
10
|
+
|
|
11
|
+
Recognize a request to change mode by meaning, in any language. Read decision.mode_status for the current externalTaskId and call decision.mode with that version as expectedVersion, the same task, repoRoot and conversation language. This opens the same three-choice selector, even while Autonomous is active. Mode selection is always the user's native answer. A comment, explanation request, dismissal or timeout does not select a mode. Do not make the person type a mode name or remember a command.
|
|
12
|
+
|
|
13
|
+
questionnaire.ask and questionnaire.resume route according to the task's current mode. For reason: delegated_decision_required, inspect all choices and call questionnaire.decide with the exact questionnaireId, requestKey, contentHash and modeVersion plus the chosen answer, reason, alternativesConsidered and userInterestReview. Then retry the owning operation. This is recorded as delegated_agent, never as the user's native answer. Use questionnaire.answer_from_host only for a real permitted blocking native response.
|
|
14
|
+
|
|
15
|
+
For agent-authored questions, set impact: routine only when the choice stays within authorized scope and is low-impact and reversible. Releases, destructive data changes, access changes, sensitive-file disclosure, added spending, major scope changes and rule/validation exceptions are critical. Omitted impact is critical. Owning operations enforce their own floor: task start can delegate a separate worktree, while moving the current folder or staying on its branch is critical. In Approve for me, use presentation: host_native if the selected option needs critical approval. Never relabel a critical decision to suppress its form.
|
|
16
|
+
|
|
17
|
+
A mode is local to this account, API origin, repository/project and task. Other tasks and accounts do not inherit it. Changing the mode affects pending and future decisions immediately. An unconsumed agent answer from an older mode version must be re-evaluated with a new question identity (or the owning operation's next decisionAttempt); never replay it as fresh consent. Actual native answers remain valid for their original exact decision. When questionnaire.resume receives a mode answer, execute its returned decision.mode retry to apply the selection. A withdrawn mode question needs a new decisionAttempt with the current expectedVersion.
|
|
18
|
+
|
|
19
|
+
Modes change who decides, not what the tools can do. Existing membership, source, worktree, lease, validation, release and host-permission checks remain in force. A mode alone skips no check. An explicitly supported validation waiver still uses its exact bound question and normal verification; Autonomous records delegated provenance, Approve for me asks the user. Credentials and browser sign-in continue through their normal interactive flows. Host approval denials cannot be overridden by an EM mode.
|
|
20
|
+
|
|
21
|
+
When the task mode calls for a user answer, use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. Existing answers remain valid through retries and handoffs. Web pages are limited to sign in, sign up, initial password change, and email verification.
|
|
4
22
|
|
|
5
23
|
## Required decisions stay pending
|
|
6
24
|
|
|
@@ -26,7 +44,7 @@ one asked before any task started). Every other task's question appears under
|
|
|
26
44
|
`otherTasksPendingQuestionnaires` as a one-line summary naming its task: enough to recognise,
|
|
27
45
|
never enough to answer here. Resume it only in the chat working on that task.
|
|
28
46
|
|
|
29
|
-
|
|
47
|
+
For a native decision, only an explicit, valid accepted response answers the question. A timeout, dismissed form, empty response, invalid response, connection loss or ended turn is not an answer. These events leave the decision pending. Never substitute the recommended choice or treat silence as consent. The form's cancel or decline button dismisses the form; if abandoning the work is a meaningful decision, offer it as an explicit answer in the question. A question left pending this way stays pending; show it again once, on the user's next message, and then wait rather than re-asking on every following turn.
|
|
30
48
|
|
|
31
49
|
State the decision in `message` and one useful example in `example`, in the language the user is writing in. Never put a resource key, hash, task ID or other internal identifier in a question's text — it explains nothing to the person answering and only makes the question harder to read.
|
|
32
50
|
|