engineering-memory 1.11.11 → 1.11.12
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 +2 -2
- package/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/mcp/onboarding-tools.js +833 -154
- package/runtime/dist/src/mcp/questionnaire-tools.js +55 -16
- package/runtime/dist/src/mcp/tool-definitions.js +103 -16
- package/runtime/dist/src/mcp/worktree-tools.js +349 -91
- package/runtime/dist/src/runtime/bridge-service.js +111 -12
- package/runtime/dist/src/runtime/create-bridge-service.js +2 -0
- package/runtime/dist/src/runtime/language-store.js +25 -0
- package/runtime/dist/src/runtime/offline-outbox.js +4 -4
- package/runtime/dist/src/runtime/privacy-detector.js +14 -5
- package/runtime/dist/src/runtime/questionnaire-store.js +26 -7
- package/runtime/dist/src/runtime/task-start.js +115 -33
- package/skill/references/questionnaires.md +8 -3
|
@@ -13,6 +13,7 @@ import { startPhaseTimer } from './phase-timer.js';
|
|
|
13
13
|
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
14
14
|
import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
|
|
15
15
|
import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
|
|
16
|
+
import { restrictedValueKind } from './privacy-detector.js';
|
|
16
17
|
import { principalFingerprint } from './principal-state.js';
|
|
17
18
|
import { sameHolder, suggestedBranchNames, taskStartChoice } from './task-start.js';
|
|
18
19
|
import { BridgeRecoveryError } from './recovery-error.js';
|
|
@@ -49,13 +50,21 @@ export class BridgeService {
|
|
|
49
50
|
managesWorktrees() {
|
|
50
51
|
return Boolean(this.dependencies.worktreePool);
|
|
51
52
|
}
|
|
53
|
+
async language(told) {
|
|
54
|
+
const accessToken = await this.dependencies.credentials.get('access-token');
|
|
55
|
+
const principalHash = accessToken ? principalFingerprint(accessToken) : sha256('anonymous');
|
|
56
|
+
if (told)
|
|
57
|
+
await this.dependencies.languages.remember(principalHash, told);
|
|
58
|
+
return told ?? (await this.dependencies.languages.read(principalHash));
|
|
59
|
+
}
|
|
52
60
|
async questionnaireAsk(input, previousDefinitions = [], owner) {
|
|
53
61
|
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
54
62
|
const { repoRoot: _repoRoot, preparation: _preparation, presentation: _presentation, ...definition } = input;
|
|
55
63
|
const askingTask = owner || input.preparation
|
|
56
64
|
? null
|
|
57
65
|
: await this.checkoutTask(scope.repoFingerprint, await this.dependencies.repositories.git.findRoot(input.repoRoot ?? process.cwd()));
|
|
58
|
-
|
|
66
|
+
const record = await this.dependencies.questionnaires.ask(scope, definition, previousDefinitions, owner ?? (askingTask ? { tool: 'questionnaire.ask', externalTaskId: askingTask } : undefined));
|
|
67
|
+
return { ...record, language: record.language ?? (await this.language()) };
|
|
59
68
|
}
|
|
60
69
|
async checkoutTask(repoFingerprint, repoRoot) {
|
|
61
70
|
const worktreeId = sha256(await canonicalPath(repoRoot));
|
|
@@ -71,7 +80,7 @@ export class BridgeService {
|
|
|
71
80
|
const record = await this.dependencies.questionnaires.get(scope, input.questionnaireId);
|
|
72
81
|
if (!record)
|
|
73
82
|
throw refuse('No questionnaire exists for this account and repository binding.', 'session.entry');
|
|
74
|
-
return record;
|
|
83
|
+
return { ...record, language: record.language ?? (await this.language()) };
|
|
75
84
|
}
|
|
76
85
|
async questionnaireWithdraw(input) {
|
|
77
86
|
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
@@ -1242,7 +1251,9 @@ export class BridgeService {
|
|
|
1242
1251
|
? await this.dependencies.questionnaires.get(scope, input.questionnaireId)
|
|
1243
1252
|
: null;
|
|
1244
1253
|
const digest = sha256(stableStringify(input.choice));
|
|
1245
|
-
if (question?.answer?.choice !== 'approve' ||
|
|
1254
|
+
if (question?.answer?.choice !== 'approve' ||
|
|
1255
|
+
(question.binding?.choiceDigest !== digest &&
|
|
1256
|
+
!question.message.includes(digest)))
|
|
1246
1257
|
throw refuse('Approve the exact mode and profile through project.onboard before continuing.', 'session.entry');
|
|
1247
1258
|
return asJsonValue(await this.dependencies.onboarding.save(scope, input.choice));
|
|
1248
1259
|
}
|
|
@@ -1609,14 +1620,45 @@ export class BridgeService {
|
|
|
1609
1620
|
}
|
|
1610
1621
|
return [...pending];
|
|
1611
1622
|
}
|
|
1612
|
-
ruleDeviationQuestions(input) {
|
|
1623
|
+
async ruleDeviationQuestions(input) {
|
|
1624
|
+
const files = reviewedFiles(input);
|
|
1625
|
+
if (!files.some(({ rules }) => rules.some(isDeviation)))
|
|
1626
|
+
return [];
|
|
1613
1627
|
const language = input.language ?? 'en';
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1628
|
+
const other = language === 'tr' ? 'en' : 'tr';
|
|
1629
|
+
const titles = await this.governingRuleTitles(input).catch(() => new Map());
|
|
1630
|
+
return files.flatMap(({ path, rules }) => rules.filter(isDeviation).map((rule) => {
|
|
1631
|
+
const title = titles.get(rule.resourceId);
|
|
1632
|
+
return {
|
|
1633
|
+
definition: ruleDeviationQuestion(input.taskId, path, rule, title, language),
|
|
1634
|
+
previousDefinitions: [
|
|
1635
|
+
ruleDeviationQuestion(input.taskId, path, rule, title, other),
|
|
1636
|
+
earlierRuleDeviationQuestion(input.taskId, path, rule, 'en'),
|
|
1637
|
+
earlierRuleDeviationQuestion(input.taskId, path, rule, 'tr'),
|
|
1638
|
+
],
|
|
1639
|
+
};
|
|
1640
|
+
}));
|
|
1641
|
+
}
|
|
1642
|
+
async governingRuleTitles(input) {
|
|
1643
|
+
const { repoFingerprint } = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
|
|
1644
|
+
const pointer = await this.dependencies.activeContexts.loadForTask(repoFingerprint, input.taskId);
|
|
1645
|
+
const prepared = pointer
|
|
1646
|
+
? await this.dependencies.client.readCached(resumeCacheKey(pointer.sessionId, repoFingerprint, 0))
|
|
1647
|
+
: null;
|
|
1648
|
+
const groups = objectValue(prepared?.data)?.governingRules;
|
|
1649
|
+
return new Map((Array.isArray(groups) ? groups : [])
|
|
1650
|
+
.flatMap((group) => {
|
|
1651
|
+
const rules = objectValue(group)?.rules;
|
|
1652
|
+
return Array.isArray(rules) ? rules : [];
|
|
1653
|
+
})
|
|
1654
|
+
.flatMap((entry) => {
|
|
1655
|
+
const rule = objectValue(entry);
|
|
1656
|
+
return typeof rule?.resourceId === 'string' &&
|
|
1657
|
+
typeof rule.title === 'string' &&
|
|
1658
|
+
!restrictedValueKind(rule.title, 'message', true)
|
|
1659
|
+
? [[rule.resourceId, rule.title]]
|
|
1660
|
+
: [];
|
|
1661
|
+
}));
|
|
1620
1662
|
}
|
|
1621
1663
|
async taskSelfReview(input) {
|
|
1622
1664
|
return await this.execute(async () => {
|
|
@@ -3380,6 +3422,7 @@ export class BridgeService {
|
|
|
3380
3422
|
async sessionEntry(input = {}) {
|
|
3381
3423
|
return await this.execute(async () => {
|
|
3382
3424
|
const timer = startPhaseTimer('session.entry');
|
|
3425
|
+
await this.language(input.language);
|
|
3383
3426
|
const authentication = await this.dependencies.browserAuth.status();
|
|
3384
3427
|
timer.mark('auth_status');
|
|
3385
3428
|
let repository;
|
|
@@ -4502,8 +4545,64 @@ export function ruleDeviationQuestionnaireId(taskId, path, rule) {
|
|
|
4502
4545
|
return ('rule-deviation-' +
|
|
4503
4546
|
sha256(stableStringify({ taskId, path, resourceKey: rule.resourceKey, issue: rule.issue })));
|
|
4504
4547
|
}
|
|
4505
|
-
function ruleDeviationQuestion(taskId, path, rule, language) {
|
|
4548
|
+
function ruleDeviationQuestion(taskId, path, rule, title, language) {
|
|
4506
4549
|
const copy = ruleDeviationCopy[language];
|
|
4550
|
+
const named = rule.resourceKey && !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(rule.resourceKey)
|
|
4551
|
+
? rule.resourceKey
|
|
4552
|
+
: undefined;
|
|
4553
|
+
return {
|
|
4554
|
+
questionnaireId: ruleDeviationQuestionnaireId(taskId, path, rule),
|
|
4555
|
+
message: [
|
|
4556
|
+
copy.ask(path),
|
|
4557
|
+
`${copy.rule}: ${title ?? named ?? copy.unnamed}`,
|
|
4558
|
+
`${copy.deviation}: ${rule.issue ?? ''}`,
|
|
4559
|
+
].join('\n'),
|
|
4560
|
+
context: copy.context,
|
|
4561
|
+
example: copy.example,
|
|
4562
|
+
language,
|
|
4563
|
+
options: [
|
|
4564
|
+
{ id: 'approve', label: copy.approve[0], description: copy.approve[1] },
|
|
4565
|
+
{ id: 'change', label: copy.change[0], description: copy.change[1] },
|
|
4566
|
+
],
|
|
4567
|
+
binding: { taskId, path, resourceId: rule.resourceId },
|
|
4568
|
+
};
|
|
4569
|
+
}
|
|
4570
|
+
const ruleDeviationCopy = {
|
|
4571
|
+
en: {
|
|
4572
|
+
ask: (path) => `${path} breaks a rule of this project. Keep the code as it is?`,
|
|
4573
|
+
rule: 'Rule',
|
|
4574
|
+
deviation: 'Deviation',
|
|
4575
|
+
unnamed: 'an engineering rule whose name is not recorded',
|
|
4576
|
+
context: "A rule conflict is yours to decide, not the agent's. Keeping the code records a deviation on this file against that rule: it travels with the self review, stays in the project memory and is what a later task reads before it touches this file.",
|
|
4577
|
+
example: 'The next task that opens this file sees the rule and this recorded deviation together, instead of reading the code as if it followed the rule.',
|
|
4578
|
+
approve: [
|
|
4579
|
+
'Keep the code and record the deviation',
|
|
4580
|
+
'The deviation is recorded for this file and the self review continues.',
|
|
4581
|
+
],
|
|
4582
|
+
change: [
|
|
4583
|
+
'Change the code to follow the rule',
|
|
4584
|
+
'Nothing is recorded. The self review stops until the code follows the rule and is reviewed again.',
|
|
4585
|
+
],
|
|
4586
|
+
},
|
|
4587
|
+
tr: {
|
|
4588
|
+
ask: (path) => `${path} dosyası bu projenin bir kuralına uymuyor. Kod olduğu gibi kalsın mı?`,
|
|
4589
|
+
rule: 'Kural',
|
|
4590
|
+
deviation: 'Sapma',
|
|
4591
|
+
unnamed: 'adı kayıtlı olmayan bir mühendislik kuralı',
|
|
4592
|
+
context: 'Kural çatışmasında kararı kodu yazan değil sen verirsin. Kodu olduğu gibi bırakırsan bu dosya için o kurala ait bir sapma kaydedilir: sapma incelemeyle birlikte gider, proje hafızasında kalır ve bu dosyaya sonradan dokunan görev önce onu okur.',
|
|
4593
|
+
example: 'Bu dosyayı sonra açan görev, kuralı ve kaydedilen bu sapmayı birlikte görür; kodu kurala uyuyormuş gibi okumaz.',
|
|
4594
|
+
approve: [
|
|
4595
|
+
'Kodu olduğu gibi bırak ve sapmayı kaydet',
|
|
4596
|
+
'Sapma bu dosya için kaydedilir ve inceleme devam eder.',
|
|
4597
|
+
],
|
|
4598
|
+
change: [
|
|
4599
|
+
'Kodu kurala uyacak şekilde değiştir',
|
|
4600
|
+
'Hiçbir şey kaydedilmez. Kod kurala uyup yeniden incelenene kadar inceleme durur.',
|
|
4601
|
+
],
|
|
4602
|
+
},
|
|
4603
|
+
};
|
|
4604
|
+
function earlierRuleDeviationQuestion(taskId, path, rule, language) {
|
|
4605
|
+
const copy = earlierRuleDeviationCopy[language];
|
|
4507
4606
|
return {
|
|
4508
4607
|
questionnaireId: ruleDeviationQuestionnaireId(taskId, path, rule),
|
|
4509
4608
|
message: copy.message(path, rule.resourceKey ?? rule.resourceId, rule.issue ?? ''),
|
|
@@ -4514,7 +4613,7 @@ function ruleDeviationQuestion(taskId, path, rule, language) {
|
|
|
4514
4613
|
],
|
|
4515
4614
|
};
|
|
4516
4615
|
}
|
|
4517
|
-
const
|
|
4616
|
+
const earlierRuleDeviationCopy = {
|
|
4518
4617
|
en: {
|
|
4519
4618
|
message: (path, resourceKey, issue) => `${path} does not follow ${resourceKey}: ${issue}`,
|
|
4520
4619
|
approve: 'Keep the code as written and record the deviation',
|
|
@@ -14,6 +14,7 @@ import { OfflineOutbox } from './offline-outbox.js';
|
|
|
14
14
|
import { ActiveContextStore } from './active-context-store.js';
|
|
15
15
|
import { RepositoryDecisionStore } from './repository-decision-store.js';
|
|
16
16
|
import { ShadowNoticeStore } from './shadow-notice-store.js';
|
|
17
|
+
import { LanguageStore } from './language-store.js';
|
|
17
18
|
import { UpdateChoiceStore } from './update-choice-store.js';
|
|
18
19
|
import { PrincipalStateGuard } from './principal-state.js';
|
|
19
20
|
import { OnboardingStore } from './onboarding-store.js';
|
|
@@ -73,6 +74,7 @@ export function createBridgeService(options = {}) {
|
|
|
73
74
|
repositoryDecisions,
|
|
74
75
|
updateChoices,
|
|
75
76
|
shadowNotices,
|
|
77
|
+
languages: new LanguageStore(stateRoot),
|
|
76
78
|
questionnaires: new QuestionnaireStore(stateRoot),
|
|
77
79
|
onboarding: new OnboardingStore(stateRoot),
|
|
78
80
|
clientVersion: options.clientVersion === undefined ? config.clientVersion : options.clientVersion,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readJson, writeJson } from '../utilities/files.js';
|
|
3
|
+
export class LanguageStore {
|
|
4
|
+
path;
|
|
5
|
+
root;
|
|
6
|
+
constructor(stateRoot) {
|
|
7
|
+
this.root = join(stateRoot, 'client');
|
|
8
|
+
this.path = join(this.root, 'language.json');
|
|
9
|
+
}
|
|
10
|
+
async read(principalHash) {
|
|
11
|
+
const remembered = await readJson(this.path, this.root);
|
|
12
|
+
return remembered?.principals[principalHash] ?? remembered?.machine;
|
|
13
|
+
}
|
|
14
|
+
async remember(principalHash, language) {
|
|
15
|
+
const remembered = await readJson(this.path, this.root);
|
|
16
|
+
if (remembered?.machine === language && remembered.principals[principalHash] === language)
|
|
17
|
+
return;
|
|
18
|
+
await writeJson(this.path, {
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
machine: language,
|
|
21
|
+
principals: { ...remembered?.principals, [principalHash]: language },
|
|
22
|
+
}, this.root);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=language-store.js.map
|
|
@@ -151,10 +151,10 @@ export class OfflineOutbox {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
-
export function assertSafeToPersist(value, key = '', path = key) {
|
|
154
|
+
export function assertSafeToPersist(value, key = '', path = key, questionText = false) {
|
|
155
155
|
if (typeof value === 'string' || typeof value === 'number') {
|
|
156
156
|
const detector = typeof value === 'string'
|
|
157
|
-
? restrictedValueKind(value, key)
|
|
157
|
+
? restrictedValueKind(value, key, questionText)
|
|
158
158
|
: restrictedNumberKind(value, key);
|
|
159
159
|
if (detector) {
|
|
160
160
|
throw new Error(`PII or credentials cannot be persisted: ${path || '(value)'} (${detector})`);
|
|
@@ -162,7 +162,7 @@ export function assertSafeToPersist(value, key = '', path = key) {
|
|
|
162
162
|
return;
|
|
163
163
|
}
|
|
164
164
|
if (Array.isArray(value)) {
|
|
165
|
-
value.forEach((item, index) => assertSafeToPersist(item, key, `${path}[${index}]
|
|
165
|
+
value.forEach((item, index) => assertSafeToPersist(item, key, `${path}[${index}]`, questionText));
|
|
166
166
|
return;
|
|
167
167
|
}
|
|
168
168
|
if (value !== null && typeof value === 'object') {
|
|
@@ -171,7 +171,7 @@ export function assertSafeToPersist(value, key = '', path = key) {
|
|
|
171
171
|
if (field) {
|
|
172
172
|
throw new Error(`Sensitive field cannot be persisted: ${childPath(path, childKey)} (${field})`);
|
|
173
173
|
}
|
|
174
|
-
assertSafeToPersist(item, childKey, childPath(path, childKey));
|
|
174
|
+
assertSafeToPersist(item, childKey, childPath(path, childKey), questionText);
|
|
175
175
|
});
|
|
176
176
|
}
|
|
177
177
|
}
|
|
@@ -13,6 +13,14 @@ const repositoryPathFields = new Set([
|
|
|
13
13
|
'pathChanges',
|
|
14
14
|
'templatePath',
|
|
15
15
|
]);
|
|
16
|
+
const questionTextFields = new Set([
|
|
17
|
+
'message',
|
|
18
|
+
'context',
|
|
19
|
+
'example',
|
|
20
|
+
'label',
|
|
21
|
+
'description',
|
|
22
|
+
'title',
|
|
23
|
+
]);
|
|
16
24
|
const topLevelDomains = new Set([
|
|
17
25
|
'com',
|
|
18
26
|
'net',
|
|
@@ -151,8 +159,9 @@ export function restrictedNumberKind(value, key) {
|
|
|
151
159
|
? 'phone number'
|
|
152
160
|
: null;
|
|
153
161
|
}
|
|
154
|
-
export function restrictedValueKind(value, key) {
|
|
162
|
+
export function restrictedValueKind(value, key, questionText = false) {
|
|
155
163
|
const repositoryPath = repositoryPathFields.has(key);
|
|
164
|
+
const prose = questionText && questionTextFields.has(key);
|
|
156
165
|
if (hasEmailAddress(value, repositoryPath))
|
|
157
166
|
return 'email address';
|
|
158
167
|
if (privateKeyPattern.test(value))
|
|
@@ -167,9 +176,9 @@ export function restrictedValueKind(value, key) {
|
|
|
167
176
|
return 'personal home directory';
|
|
168
177
|
if (hasBearerCredential(value))
|
|
169
178
|
return 'bearer credential';
|
|
170
|
-
if (hasPhoneNumber(value))
|
|
179
|
+
if (hasPhoneNumber(value, prose))
|
|
171
180
|
return 'phone number';
|
|
172
|
-
if (!repositoryPath && hasIpv4Address(value))
|
|
181
|
+
if (!repositoryPath && !prose && hasIpv4Address(value))
|
|
173
182
|
return 'network address';
|
|
174
183
|
if (hasSecretAssignment(value))
|
|
175
184
|
return 'assigned secret';
|
|
@@ -234,11 +243,11 @@ function hasIpv4Address(value) {
|
|
|
234
243
|
}
|
|
235
244
|
return false;
|
|
236
245
|
}
|
|
237
|
-
function hasPhoneNumber(value) {
|
|
246
|
+
function hasPhoneNumber(value, prose = false) {
|
|
238
247
|
const trimmed = value.trim();
|
|
239
248
|
if (uuidPattern.test(trimmed))
|
|
240
249
|
return false;
|
|
241
|
-
if (/^\d{10,11}$/.test(trimmed))
|
|
250
|
+
if (!prose && /^\d{10,11}$/.test(trimmed))
|
|
242
251
|
return true;
|
|
243
252
|
if (/(?:^|[^A-Z0-9])0?5\d{2}[\s.-]?\d{3}[\s.-]?\d{2}[\s.-]?\d{2}(?![A-Z0-9])/i.test(value)) {
|
|
244
253
|
return true;
|
|
@@ -7,12 +7,14 @@ import { sha256, stableStringify } from '../utilities/hash.js';
|
|
|
7
7
|
import { assertSafeToPersist } from './offline-outbox.js';
|
|
8
8
|
const identifier = z.string().regex(/^[A-Za-z0-9_-]{1,100}$/);
|
|
9
9
|
const questionId = z.string().regex(/^[a-z][a-z0-9_]{0,39}$/);
|
|
10
|
+
export const languageTag = z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/);
|
|
10
11
|
const optionSchema = z.strictObject({
|
|
11
12
|
id: z
|
|
12
13
|
.string()
|
|
13
14
|
.regex(/^[A-Za-z0-9_-]{1,80}$/)
|
|
14
|
-
.refine((value) => value !== '__other__'),
|
|
15
|
+
.refine((value) => value !== '__other__' && value !== '__explain__'),
|
|
15
16
|
label: z.string().trim().min(1).max(240),
|
|
17
|
+
description: z.string().trim().min(1).max(400).optional(),
|
|
16
18
|
});
|
|
17
19
|
const textFieldSchema = z.strictObject({
|
|
18
20
|
title: z.string().trim().min(1).max(120),
|
|
@@ -27,6 +29,8 @@ const subQuestionSchema = z
|
|
|
27
29
|
.strictObject({
|
|
28
30
|
id: questionId,
|
|
29
31
|
message: z.string().trim().min(1).max(600),
|
|
32
|
+
context: z.string().trim().min(1).max(1200).optional(),
|
|
33
|
+
example: z.string().trim().min(1).max(600).optional(),
|
|
30
34
|
options: z
|
|
31
35
|
.array(optionSchema)
|
|
32
36
|
.min(2)
|
|
@@ -73,10 +77,23 @@ function attachDefinitionChecks(schema) {
|
|
|
73
77
|
const questionnaireDefinitionShape = z.strictObject({
|
|
74
78
|
questionnaireId: identifier,
|
|
75
79
|
message: z.string().trim().min(1).max(2000),
|
|
76
|
-
|
|
77
|
-
.
|
|
80
|
+
context: z
|
|
81
|
+
.string()
|
|
82
|
+
.trim()
|
|
83
|
+
.min(1)
|
|
84
|
+
.max(1200)
|
|
85
|
+
.optional()
|
|
86
|
+
.describe('Why this is being asked, in the language of the conversation.'),
|
|
87
|
+
example: z
|
|
88
|
+
.string()
|
|
89
|
+
.trim()
|
|
90
|
+
.min(1)
|
|
91
|
+
.max(600)
|
|
92
|
+
.optional()
|
|
93
|
+
.describe('One concrete example of what the decision changes.'),
|
|
94
|
+
language: languageTag
|
|
78
95
|
.optional()
|
|
79
|
-
.describe('Language
|
|
96
|
+
.describe('Language the user is writing in, as a BCP-47 tag such as tr, en or pt-BR. Write the message, context, example, option labels and descriptions in that language too.'),
|
|
80
97
|
options: z
|
|
81
98
|
.array(optionSchema)
|
|
82
99
|
.min(2)
|
|
@@ -234,7 +251,7 @@ export class QuestionnaireStore {
|
|
|
234
251
|
scopeSchema.parse(scope);
|
|
235
252
|
if (owner)
|
|
236
253
|
ownerSchema.parse(owner);
|
|
237
|
-
assertSafeToPersist(definition);
|
|
254
|
+
assertSafeToPersist(definition, '', '', true);
|
|
238
255
|
const contentHash = sha256(stableStringify(definition));
|
|
239
256
|
const question = {
|
|
240
257
|
...definition,
|
|
@@ -261,6 +278,8 @@ export class QuestionnaireStore {
|
|
|
261
278
|
const definition = boundDefinitionSchema.parse({
|
|
262
279
|
questionnaireId: question.questionnaireId,
|
|
263
280
|
message: question.message,
|
|
281
|
+
context: question.context,
|
|
282
|
+
example: question.example,
|
|
264
283
|
language: question.language,
|
|
265
284
|
options: question.options,
|
|
266
285
|
allowFreeText: question.allowFreeText,
|
|
@@ -274,7 +293,7 @@ export class QuestionnaireStore {
|
|
|
274
293
|
question.contentHash !== sha256(stableStringify(definition))) {
|
|
275
294
|
throw new Error('Questionnaire scope or content does not match its durable record.');
|
|
276
295
|
}
|
|
277
|
-
assertSafeToPersist(question);
|
|
296
|
+
assertSafeToPersist(question, '', '', true);
|
|
278
297
|
const rawAnswer = await readJson(this.pathFor(scope, questionnaireId, 'answer.json'), this.root);
|
|
279
298
|
if (!rawAnswer)
|
|
280
299
|
return { ...question, status: 'pending' };
|
|
@@ -427,7 +446,7 @@ export class QuestionnaireStore {
|
|
|
427
446
|
return join(this.scopeRoot(scope), sha256(questionnaireId), filename);
|
|
428
447
|
}
|
|
429
448
|
async publish(path, value) {
|
|
430
|
-
assertSafeToPersist(value);
|
|
449
|
+
assertSafeToPersist(value, '', '', true);
|
|
431
450
|
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
|
432
451
|
await writeJson(temporaryPath, value, this.root);
|
|
433
452
|
try {
|
|
@@ -1,4 +1,44 @@
|
|
|
1
1
|
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
2
|
+
const startCopy = {
|
|
3
|
+
en: {
|
|
4
|
+
start: (task) => `${task}: how should this task start?`,
|
|
5
|
+
context: 'A task works on its own branch so that unfinished work never mixes with what is already open in this folder. Nothing is created, checked out or reserved until you answer. Your answer is kept, so asking for the same task again does not ask you twice.',
|
|
6
|
+
example: 'Choosing a separate worktree creates the branch in a folder of its own and leaves this folder on the branch and the files you have open right now.',
|
|
7
|
+
keepContext: 'This task continues on the branch this folder is already on. Nothing is created, checked out or reserved until you answer. Your answer is kept, so asking for the same task again does not ask you twice.',
|
|
8
|
+
roles: { development: 'development', production: 'production', test: 'test' },
|
|
9
|
+
holder: (task, branch, when) => `${task} holds this folder (branch ${branch}, last activity ${when}). If it moves out it stays open, keeps its own branch and continues in a separate worktree when it is resumed.`,
|
|
10
|
+
unknown: 'unknown',
|
|
11
|
+
separate: 'A managed folder of its own is prepared and the new branch is created there. This folder is left on its branch and its files are not touched.',
|
|
12
|
+
inPlace: 'The new branch is created here and this folder is switched to it.',
|
|
13
|
+
moveOut: (task) => `${task} keeps its branch and its work and continues in a separate worktree later. The new branch is checked out here.`,
|
|
14
|
+
stay: (source) => `Nothing new is created. The work continues on ${source} in this folder, so every commit lands on that branch.`,
|
|
15
|
+
later: 'Nothing is created or reserved. The task waits until you ask to start it.',
|
|
16
|
+
fetched: (role, source) => `The project's ${role} branch ${source} is fetched from the remote and the new branch starts from that exact commit.`,
|
|
17
|
+
pinned: (source) => `The new branch starts where this folder is right now, ${source}. Nothing is fetched.`,
|
|
18
|
+
typedBranch: 'Write a branch name on origin below. It is fetched and the new branch starts from it.',
|
|
19
|
+
suggested: 'The branch is created with this name.',
|
|
20
|
+
typedName: 'Write the name below. A name that already exists is refused and the form is asked again.',
|
|
21
|
+
},
|
|
22
|
+
tr: {
|
|
23
|
+
start: (task) => `${task} nasıl başlasın?`,
|
|
24
|
+
context: 'Her görev kendi dalında çalışır; böylece yarım kalan iş, bu klasörde açık olan işe karışmaz. Sen cevaplayana kadar hiçbir klasör açılmaz, hiçbir dal oluşturulmaz, hiçbir yer ayrılmaz. Verdiğin cevap saklanır; aynı görev için ikinci kez sorulmaz.',
|
|
25
|
+
example: 'Ayrı worktree’yi seçersen yeni dal kendi klasöründe açılır; bu klasör şu anki dalında ve açık dosyalarıyla olduğu gibi kalır.',
|
|
26
|
+
keepContext: 'Bu görev, klasörün zaten üzerinde olduğu dalda devam eder. Sen cevaplayana kadar hiçbir şey oluşturulmaz, hiçbir yer ayrılmaz. Verdiğin cevap saklanır; aynı görev için yeniden sorulmaz.',
|
|
27
|
+
roles: { development: 'geliştirme', production: 'canlı', test: 'test' },
|
|
28
|
+
holder: (task, branch, when) => `${task} görevi bu klasörü tutuyor (dal ${branch}, son etkinlik ${when}). Buradan çıkarılırsa görev kapanmaz; kendi dalını ve işini korur, devam ettiğinde ayrı bir worktree’de açılır.`,
|
|
29
|
+
unknown: 'bilinmiyor',
|
|
30
|
+
separate: 'Görev için ayrı bir klasör hazırlanır ve yeni dal orada açılır. Bu klasör kendi dalında kalır, dosyalarına dokunulmaz.',
|
|
31
|
+
inPlace: 'Yeni dal burada açılır ve bu klasör o dala geçer.',
|
|
32
|
+
moveOut: (task) => `${task} dalını ve işini korur, sonra ayrı bir worktree’de devam eder. Yeni dal burada açılır.`,
|
|
33
|
+
stay: (source) => `Yeni bir şey oluşturulmaz. İş bu klasörde ${source} üzerinde sürer; her commit o dala gider.`,
|
|
34
|
+
later: 'Hiçbir şey oluşturulmaz, hiçbir yer ayrılmaz. Görev sen isteyene kadar beklemede kalır.',
|
|
35
|
+
fetched: (role, source) => `Projenin ${role} dalı ${source} uzak sunucudan çekilir ve yeni dal tam o commit’ten başlar.`,
|
|
36
|
+
pinned: (source) => `Yeni dal, bu klasörün şu an üzerinde olduğu ${source} noktasından başlar. Hiçbir şey çekilmez.`,
|
|
37
|
+
typedBranch: 'origin üzerindeki dal adını aşağıya yaz. O dal çekilir ve yeni dal ondan başlar.',
|
|
38
|
+
suggested: 'Dal bu adla açılır.',
|
|
39
|
+
typedName: 'Adı aşağıya yaz. Zaten var olan bir ad kabul edilmez, form yeniden sorulur.',
|
|
40
|
+
},
|
|
41
|
+
};
|
|
2
42
|
export function suggestedBranchNames(externalTaskId) {
|
|
3
43
|
const slug = externalTaskId
|
|
4
44
|
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
@@ -34,6 +74,7 @@ export function configuredBase(preferences, role) {
|
|
|
34
74
|
}
|
|
35
75
|
export function taskStartDefinition(facts) {
|
|
36
76
|
const tr = facts.language === 'tr';
|
|
77
|
+
const copy = startCopy[tr ? 'tr' : 'en'];
|
|
37
78
|
const folder = facts.folder;
|
|
38
79
|
const keepOnly = facts.keepCurrent === true || !facts.currentCommit;
|
|
39
80
|
const here = !keepOnly && !folder.managed && !folder.heldBy && folder.clean;
|
|
@@ -46,14 +87,26 @@ export function taskStartDefinition(facts) {
|
|
|
46
87
|
currentCommit: facts.currentCommit,
|
|
47
88
|
holder,
|
|
48
89
|
};
|
|
90
|
+
const heldNotice = here && folder.holder
|
|
91
|
+
? copy.holder(folder.holder.externalTaskId, folder.holder.branch ?? 'detached HEAD', readableTime(folder.holder.lastCheckpointAt, tr) ?? copy.unknown)
|
|
92
|
+
: null;
|
|
93
|
+
const withheldNotice = withheld(folder, keepOnly, tr);
|
|
94
|
+
const locationContext = [heldNotice, withheldNotice].filter(Boolean).join(' ');
|
|
49
95
|
const questions = [
|
|
50
96
|
{
|
|
51
97
|
id: 'location',
|
|
52
98
|
message: tr ? 'Nerede başlasın?' : 'Where should it start?',
|
|
99
|
+
...(locationContext ? { context: locationContext } : {}),
|
|
53
100
|
options: [
|
|
54
101
|
...(keepOnly
|
|
55
102
|
? []
|
|
56
|
-
: [
|
|
103
|
+
: [
|
|
104
|
+
{
|
|
105
|
+
id: 'worktree',
|
|
106
|
+
label: tr ? 'Ayrı bir worktree’de' : 'In a separate worktree',
|
|
107
|
+
description: copy.separate,
|
|
108
|
+
},
|
|
109
|
+
]),
|
|
57
110
|
...(here
|
|
58
111
|
? [
|
|
59
112
|
{
|
|
@@ -65,6 +118,7 @@ export function taskStartDefinition(facts) {
|
|
|
65
118
|
: tr
|
|
66
119
|
? 'Bu klasörde yeni branch olarak'
|
|
67
120
|
: 'In this folder, as a new branch',
|
|
121
|
+
description: holder ? copy.moveOut(holder.externalTaskId) : copy.inPlace,
|
|
68
122
|
},
|
|
69
123
|
]
|
|
70
124
|
: []),
|
|
@@ -75,10 +129,11 @@ export function taskStartDefinition(facts) {
|
|
|
75
129
|
label: tr
|
|
76
130
|
? 'Burada, ' + currentLabel + ' üzerinde, yeni branch açmadan'
|
|
77
131
|
: 'Here on ' + currentLabel + ', without a new branch',
|
|
132
|
+
description: copy.stay(currentLabel),
|
|
78
133
|
},
|
|
79
134
|
]
|
|
80
135
|
: []),
|
|
81
|
-
{ id: 'defer', label: tr ? 'Şimdilik başlatma' : 'Not now' },
|
|
136
|
+
{ id: 'defer', label: tr ? 'Şimdilik başlatma' : 'Not now', description: copy.later },
|
|
82
137
|
],
|
|
83
138
|
},
|
|
84
139
|
];
|
|
@@ -92,6 +147,7 @@ export function taskStartDefinition(facts) {
|
|
|
92
147
|
const roleOption = ({ role, base }) => ({
|
|
93
148
|
id: role,
|
|
94
149
|
label: base.remote + '/' + base.branch + ' (' + role + ')',
|
|
150
|
+
description: copy.fetched(copy.roles[role], base.remote + '/' + base.branch),
|
|
95
151
|
});
|
|
96
152
|
binding.bases = {
|
|
97
153
|
current: {
|
|
@@ -108,9 +164,14 @@ export function taskStartDefinition(facts) {
|
|
|
108
164
|
{
|
|
109
165
|
id: 'current',
|
|
110
166
|
label: tr ? 'Bu klasörün dalı: ' + currentLabel : 'This folder’s branch: ' + currentLabel,
|
|
167
|
+
description: copy.pinned(currentLabel),
|
|
111
168
|
},
|
|
112
169
|
...roles.slice(1).map(roleOption),
|
|
113
|
-
{
|
|
170
|
+
{
|
|
171
|
+
id: 'other',
|
|
172
|
+
label: tr ? 'Başka bir uzak dal' : 'Another remote branch',
|
|
173
|
+
description: copy.typedBranch,
|
|
174
|
+
},
|
|
114
175
|
],
|
|
115
176
|
otherAsText: true,
|
|
116
177
|
textField: {
|
|
@@ -128,8 +189,8 @@ export function taskStartDefinition(facts) {
|
|
|
128
189
|
id: 'name',
|
|
129
190
|
message: tr ? 'Yeni branch’in adı ne olsun?' : 'What should the new branch be called?',
|
|
130
191
|
options: [
|
|
131
|
-
{ id: 'suggested', label: facts.suggestedName },
|
|
132
|
-
{ id: 'custom', label: tr ? 'Başka bir ad' : 'Another name' },
|
|
192
|
+
{ id: 'suggested', label: facts.suggestedName, description: copy.suggested },
|
|
193
|
+
{ id: 'custom', label: tr ? 'Başka bir ad' : 'Another name', description: copy.typedName },
|
|
133
194
|
],
|
|
134
195
|
textField: {
|
|
135
196
|
title: tr ? 'Branch adı' : 'Branch name',
|
|
@@ -140,24 +201,7 @@ export function taskStartDefinition(facts) {
|
|
|
140
201
|
}
|
|
141
202
|
else if (!keepOnly)
|
|
142
203
|
binding.name = facts.name;
|
|
143
|
-
const
|
|
144
|
-
...(here && folder.holder
|
|
145
|
-
? [
|
|
146
|
-
tr
|
|
147
|
-
? folder.holder.externalTaskId +
|
|
148
|
-
' bu klasörü tutuyor (branch ' +
|
|
149
|
-
(folder.holder.branch ?? 'detached HEAD') +
|
|
150
|
-
', son etkinlik ' +
|
|
151
|
-
(folder.holder.lastCheckpointAt ?? 'bilinmiyor') +
|
|
152
|
-
'). Çıkarılırsa görev açık kalır ve devam ederken ayrı bir worktree’de açılır.'
|
|
153
|
-
: folder.holder.externalTaskId +
|
|
154
|
-
' holds this folder (branch ' +
|
|
155
|
-
(folder.holder.branch ?? 'detached HEAD') +
|
|
156
|
-
', last activity ' +
|
|
157
|
-
(folder.holder.lastCheckpointAt ?? 'unknown') +
|
|
158
|
-
'). If it moves out, it stays open and continues in a separate worktree when resumed.',
|
|
159
|
-
]
|
|
160
|
-
: []),
|
|
204
|
+
const chosen = [
|
|
161
205
|
...(binding.base
|
|
162
206
|
? [
|
|
163
207
|
tr
|
|
@@ -168,25 +212,57 @@ export function taskStartDefinition(facts) {
|
|
|
168
212
|
...(binding.name
|
|
169
213
|
? [tr ? 'Yeni branch: ' + binding.name + '.' : 'New branch: ' + binding.name + '.']
|
|
170
214
|
: []),
|
|
171
|
-
...(withheld(folder, keepOnly, tr) ? [withheld(folder, keepOnly, tr)] : []),
|
|
172
215
|
];
|
|
173
|
-
const
|
|
216
|
+
const shared = {
|
|
217
|
+
language: facts.language,
|
|
218
|
+
terminalChoices: [
|
|
219
|
+
{ questionId: 'location', choice: 'defer' },
|
|
220
|
+
...(keep ? [{ questionId: 'location', choice: 'keep_current' }] : []),
|
|
221
|
+
],
|
|
222
|
+
binding: JSON.parse(JSON.stringify(binding)),
|
|
223
|
+
};
|
|
224
|
+
const previous = {
|
|
174
225
|
message: [
|
|
175
226
|
tr
|
|
176
227
|
? facts.externalTaskId + ' nasıl başlasın? Sen cevaplayana kadar hiçbir şey oluşturulmaz.'
|
|
177
228
|
: facts.externalTaskId +
|
|
178
229
|
': how should this task start? Nothing is created until you answer.',
|
|
179
|
-
...
|
|
230
|
+
...(here && folder.holder
|
|
231
|
+
? [
|
|
232
|
+
tr
|
|
233
|
+
? folder.holder.externalTaskId +
|
|
234
|
+
' bu klasörü tutuyor (branch ' +
|
|
235
|
+
(folder.holder.branch ?? 'detached HEAD') +
|
|
236
|
+
', son etkinlik ' +
|
|
237
|
+
(folder.holder.lastCheckpointAt ?? 'bilinmiyor') +
|
|
238
|
+
'). Çıkarılırsa görev açık kalır ve devam ederken ayrı bir worktree’de açılır.'
|
|
239
|
+
: folder.holder.externalTaskId +
|
|
240
|
+
' holds this folder (branch ' +
|
|
241
|
+
(folder.holder.branch ?? 'detached HEAD') +
|
|
242
|
+
', last activity ' +
|
|
243
|
+
(folder.holder.lastCheckpointAt ?? 'unknown') +
|
|
244
|
+
'). If it moves out, it stays open and continues in a separate worktree when resumed.',
|
|
245
|
+
]
|
|
246
|
+
: []),
|
|
247
|
+
...chosen,
|
|
248
|
+
...(withheldNotice ? [withheldNotice] : []),
|
|
180
249
|
].join(' '),
|
|
181
|
-
|
|
250
|
+
...shared,
|
|
251
|
+
questions: questions.map(({ context: _why, options, ...question }) => ({
|
|
252
|
+
...question,
|
|
253
|
+
options: options.map(({ description: _next, ...option }) => option),
|
|
254
|
+
})),
|
|
255
|
+
};
|
|
256
|
+
const questionnaireId = 'task-start-' + sha256(stableStringify(previous));
|
|
257
|
+
return {
|
|
258
|
+
questionnaireId,
|
|
259
|
+
message: [copy.start(facts.externalTaskId), ...chosen].join(' '),
|
|
260
|
+
context: keepOnly ? copy.keepContext : copy.context,
|
|
261
|
+
example: keepOnly ? copy.stay(currentLabel) : copy.example,
|
|
262
|
+
...shared,
|
|
182
263
|
questions,
|
|
183
|
-
|
|
184
|
-
{ questionId: 'location', choice: 'defer' },
|
|
185
|
-
...(keep ? [{ questionId: 'location', choice: 'keep_current' }] : []),
|
|
186
|
-
],
|
|
187
|
-
binding: JSON.parse(JSON.stringify(binding)),
|
|
264
|
+
previous: { questionnaireId, ...previous },
|
|
188
265
|
};
|
|
189
|
-
return { questionnaireId: 'task-start-' + sha256(stableStringify(content)), ...content };
|
|
190
266
|
}
|
|
191
267
|
export function taskStartChoice(record) {
|
|
192
268
|
if (record.status !== 'answered' ||
|
|
@@ -264,6 +340,12 @@ function holderIdentity(holder) {
|
|
|
264
340
|
generation: holder.generation,
|
|
265
341
|
};
|
|
266
342
|
}
|
|
343
|
+
function readableTime(value, tr) {
|
|
344
|
+
const [, date, time] = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value ?? '') ?? [];
|
|
345
|
+
if (!date || !time)
|
|
346
|
+
return null;
|
|
347
|
+
return (tr ? date.split('-').reverse().join('.') : date) + ' ' + time + ' UTC';
|
|
348
|
+
}
|
|
267
349
|
function withheld(folder, keepOnly, tr) {
|
|
268
350
|
if (keepOnly || folder.managed || (!folder.heldBy && folder.clean))
|
|
269
351
|
return null;
|