cowork-flow 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -1
- package/README.en.md +34 -18
- package/README.md +8 -5
- package/docs/architecture.en.md +95 -0
- package/docs/architecture.md +7 -3
- package/docs/direction.en.md +71 -0
- package/docs/direction.md +6 -1
- package/docs/hosts.en.md +299 -0
- package/docs/hosts.md +3 -1
- package/docs/index.en.md +30 -0
- package/docs/index.md +2 -0
- package/docs/release.en.md +108 -0
- package/docs/release.md +2 -0
- package/package.json +2 -2
- package/presets/claude-code/.claude-plugin/plugin.json +1 -1
- package/presets/claude-code/skills/cowork-flow-bootstrap/SKILL.md +4 -0
- package/presets/codex/.codex-plugin/plugin.json +1 -1
- package/presets/codex/skills/cowork-flow-bootstrap/SKILL.md +4 -0
- package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
- package/presets/kimi-code/skills/cowork-flow-bootstrap/SKILL.md +4 -0
- package/presets/opencode/cowork-flow/skills/cowork-flow-bootstrap/SKILL.md +4 -0
- package/presets/qoder/.qoder-plugin/plugin.json +1 -1
- package/presets/qoder/skills/cowork-flow-bootstrap/SKILL.md +4 -0
- package/presets/zcode/.zcode-plugin/plugin.json +1 -1
- package/presets/zcode/skills/cowork-flow-bootstrap/SKILL.md +4 -0
- package/src/commands/init.js +129 -2
- package/src/commands/registry.js +3 -1
- package/src/commands/source-refresh.js +5 -1
- package/src/commands/sync.js +3 -0
- package/src/lib/copy-template.js +23 -5
- package/src/lib/host-assets.js +117 -2
- package/src/lib/language.js +162 -0
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/config.yaml +8 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_create_command.py +5 -3
- package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +71 -2
- package/template/.cowork-flow/scripts/infra/config.py +85 -0
- package/template/.cowork-flow/scripts/services/decision_anchor_schema.py +104 -0
- package/template/.cowork-flow/scripts/services/fact_view.py +21 -15
- package/template/.cowork-flow/scripts/services/task_context.py +13 -7
- package/template/.cowork-flow/scripts/services/task_creation.py +72 -30
- package/template/.cowork-flow/scripts/services/task_gate_profiles.py +7 -24
- package/template/.cowork-flow/spec/contracts/context-injection.md +5 -4
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +21 -14
- package/template/.cowork-flow/spec/contracts/plan-binding.md +2 -1
- package/template/.cowork-flow/spec/index.md +26 -26
- package/template/.cowork-flow/spec/references/definition-of-done.md +26 -26
- package/template/.cowork-flow/spec/references/index.md +16 -16
- package/template/.cowork-flow/spec/references/orchestration-patterns.md +71 -71
- package/template/.cowork-flow/spec/references/security-checklist.md +37 -37
- package/template/.cowork-flow/spec/references/testing-checklist.md +32 -32
- package/template/.cowork-flow/spec/runtime/host-assets.json +23 -4
- package/template/.cowork-flow/spec/runtime/index.md +10 -12
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +31 -0
- package/template/.cowork-flow/spec/schemas/index.md +5 -5
- package/template/.cowork-flow/templates/plan-template.md +91 -0
- package/template/locales/en-US/.cowork-flow/spec/contracts/decision-anchor.md +160 -0
- package/template/locales/en-US/.cowork-flow/templates/plan-template.md +91 -0
- package/template/skills/runtime-health/scripts/doctor.py +110 -0
- package/template/skills/task-planning/SKILL.md +19 -92
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_LANGUAGE = 'zh-CN';
|
|
5
|
+
export const SUPPORTED_LANGUAGES = Object.freeze(['zh-CN', 'en-US']);
|
|
6
|
+
|
|
7
|
+
const LANGUAGE_ALIASES = new Map([
|
|
8
|
+
['zh', 'zh-CN'],
|
|
9
|
+
['zh-cn', 'zh-CN'],
|
|
10
|
+
['zh_cn', 'zh-CN'],
|
|
11
|
+
['en', 'en-US'],
|
|
12
|
+
['en-us', 'en-US'],
|
|
13
|
+
['en_us', 'en-US']
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
// The config is a flat YAML document; a top-level `language:` scalar is the
|
|
17
|
+
// only place the install-time choice is recorded. A `#` starts a comment only
|
|
18
|
+
// outside quotes and only after whitespace, matching the Python reader.
|
|
19
|
+
const LANGUAGE_KEY_RE = /^language\s*:\s*(.*)$/;
|
|
20
|
+
|
|
21
|
+
function stripInlineComment(value) {
|
|
22
|
+
let quote = '';
|
|
23
|
+
let escaped = false;
|
|
24
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
25
|
+
const char = value[index];
|
|
26
|
+
if (escaped) {
|
|
27
|
+
escaped = false;
|
|
28
|
+
} else if (quote === '"' && char === '\\') {
|
|
29
|
+
escaped = true;
|
|
30
|
+
} else if (quote) {
|
|
31
|
+
if (char === quote) {
|
|
32
|
+
quote = '';
|
|
33
|
+
}
|
|
34
|
+
} else if (index === 0 && (char === '"' || char === "'")) {
|
|
35
|
+
quote = char;
|
|
36
|
+
} else if (char === '#' && (index === 0 || value[index - 1] === ' ' || value[index - 1] === '\t')) {
|
|
37
|
+
return value.slice(0, index).trimEnd();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function unquote(value) {
|
|
44
|
+
if (
|
|
45
|
+
value.length >= 2
|
|
46
|
+
&& (value[0] === '"' || value[0] === "'")
|
|
47
|
+
&& value[value.length - 1] === value[0]
|
|
48
|
+
) {
|
|
49
|
+
return value.slice(1, -1);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function normalizeLanguage(value) {
|
|
55
|
+
if (value === undefined || value === null) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return LANGUAGE_ALIASES.get(String(value).trim().toLowerCase()) ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Top-level `language:` lines only. Two keys, an empty value, a glued comment,
|
|
62
|
+
// or trailing junk after a quoted value all mean the explicit setting cannot be
|
|
63
|
+
// trusted; callers decide whether that is fatal (sync) or falls back (generation).
|
|
64
|
+
export function readLanguageSetting(content) {
|
|
65
|
+
const entries = [];
|
|
66
|
+
for (const rawLine of String(content ?? '').split(/\r?\n/)) {
|
|
67
|
+
if (/^\s/.test(rawLine)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const match = LANGUAGE_KEY_RE.exec(rawLine);
|
|
71
|
+
if (match) {
|
|
72
|
+
entries.push(match[1]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (entries.length === 0) {
|
|
76
|
+
return { present: false, raw: null, duplicate: false };
|
|
77
|
+
}
|
|
78
|
+
if (entries.length > 1) {
|
|
79
|
+
return { present: true, raw: null, duplicate: true };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
present: true,
|
|
83
|
+
raw: unquote(stripInlineComment(entries[0].trim())).trim(),
|
|
84
|
+
duplicate: false
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function readLanguageValue(content) {
|
|
89
|
+
const setting = readLanguageSetting(content);
|
|
90
|
+
return setting.present ? setting.raw : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseLanguage(content) {
|
|
94
|
+
return normalizeLanguage(readLanguageValue(content));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function readProjectLanguage(targetDir) {
|
|
98
|
+
const file = join(targetDir, '.cowork-flow', 'config.yaml');
|
|
99
|
+
let content;
|
|
100
|
+
try {
|
|
101
|
+
content = await readFile(file, 'utf8');
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error && error.code === 'ENOENT') {
|
|
104
|
+
return {
|
|
105
|
+
file,
|
|
106
|
+
present: false,
|
|
107
|
+
configured: false,
|
|
108
|
+
valid: true,
|
|
109
|
+
duplicate: false,
|
|
110
|
+
raw: null,
|
|
111
|
+
language: DEFAULT_LANGUAGE
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const setting = readLanguageSetting(content);
|
|
118
|
+
const language = setting.duplicate ? null : normalizeLanguage(setting.raw);
|
|
119
|
+
return {
|
|
120
|
+
file,
|
|
121
|
+
present: true,
|
|
122
|
+
configured: setting.present,
|
|
123
|
+
valid: !setting.present || language !== null,
|
|
124
|
+
duplicate: setting.duplicate,
|
|
125
|
+
raw: setting.raw,
|
|
126
|
+
language: language ?? DEFAULT_LANGUAGE
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Planning commands must not silently fall back on a typo: the caller is about
|
|
131
|
+
// to distribute assets for a language the project explicitly asked for.
|
|
132
|
+
export async function readProjectLanguageStrict(targetDir) {
|
|
133
|
+
const info = await readProjectLanguage(targetDir);
|
|
134
|
+
if (info.configured && !info.valid) {
|
|
135
|
+
const detail = info.duplicate
|
|
136
|
+
? 'duplicate language keys'
|
|
137
|
+
: `unsupported language ${JSON.stringify(info.raw)}`;
|
|
138
|
+
throw new Error(
|
|
139
|
+
`${detail} in ${info.file}; expected ${SUPPORTED_LANGUAGES.join(' or ')}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return info;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Write the chosen language into the config content that is about to be
|
|
146
|
+
// installed. The template already declares the key; the fallback keeps a
|
|
147
|
+
// hand-edited template from losing the choice.
|
|
148
|
+
export function withLanguage(content, language) {
|
|
149
|
+
const canonical = normalizeLanguage(language);
|
|
150
|
+
if (!canonical) {
|
|
151
|
+
throw new Error(`Unsupported language: ${language}`);
|
|
152
|
+
}
|
|
153
|
+
const setting = readLanguageSetting(content);
|
|
154
|
+
if (setting.duplicate) {
|
|
155
|
+
throw new Error('Cannot rewrite a config with duplicate language keys');
|
|
156
|
+
}
|
|
157
|
+
const line = `language: "${canonical}"`;
|
|
158
|
+
if (setting.present) {
|
|
159
|
+
return content.replace(/^language\s*:.*$/m, line);
|
|
160
|
+
}
|
|
161
|
+
return `${content.replace(/\s*$/, '')}\n\n${line}\n`;
|
|
162
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.4.0
|
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
#
|
|
4
4
|
# All values have sensible defaults. Only override what you need.
|
|
5
5
|
|
|
6
|
+
#-------------------------------------------------------------------------------
|
|
7
|
+
# Language
|
|
8
|
+
#-------------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
# Output language for generated task and plan artifacts: zh-CN or en-US.
|
|
11
|
+
# This file is never overwritten by project sync, including sync --force.
|
|
12
|
+
language: "zh-CN"
|
|
13
|
+
|
|
6
14
|
#-------------------------------------------------------------------------------
|
|
7
15
|
# Codex Runtime
|
|
8
16
|
#-------------------------------------------------------------------------------
|
|
@@ -133,10 +133,12 @@ def _print_create_result(result) -> None:
|
|
|
133
133
|
file=sys.stderr,
|
|
134
134
|
)
|
|
135
135
|
if result.generated_anchor:
|
|
136
|
-
|
|
137
|
-
f" {colored('[OK]', Colors.GREEN)} "
|
|
136
|
+
message = (
|
|
138
137
|
"Generated decision-anchor.md from plan"
|
|
138
|
+
if result.generated_anchor_from_plan
|
|
139
|
+
else "Created decision-anchor.md scaffold"
|
|
139
140
|
)
|
|
141
|
+
print(f" {colored('[OK]', Colors.GREEN)} {message}")
|
|
140
142
|
|
|
141
143
|
print(
|
|
142
144
|
colored(f"Created task: {dir_name}", Colors.GREEN),
|
|
@@ -150,7 +152,7 @@ def _print_next_steps() -> None:
|
|
|
150
152
|
print("", file=sys.stderr)
|
|
151
153
|
print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
|
|
152
154
|
print(
|
|
153
|
-
" 1.
|
|
155
|
+
" 1. Fill in decision-anchor.md (a scaffold is created)",
|
|
154
156
|
file=sys.stderr,
|
|
155
157
|
)
|
|
156
158
|
print(
|
|
@@ -31,11 +31,14 @@ CAPABILITY_STATUS_VALUES = (
|
|
|
31
31
|
"experimental",
|
|
32
32
|
"unsupported",
|
|
33
33
|
)
|
|
34
|
-
MANIFEST_KEYS = frozenset(("schemaVersion", "capabilityValues", "capabilityMatrix", "platforms", "excludedPrefixes", "syncPolicy"))
|
|
34
|
+
MANIFEST_KEYS = frozenset(("schemaVersion", "capabilityValues", "capabilityMatrix", "platforms", "excludedPrefixes", "localization", "syncPolicy"))
|
|
35
35
|
PLATFORM_KEYS = frozenset(("id", "displayName", "aliases", "detectAny", "assetPrefixes", "assetFiles", "skillReadRoot", "skillDiscovery", "adapterPath", "capabilities", "commandTargets", "payload"))
|
|
36
36
|
SKILL_DISCOVERY_KEYS = frozenset(("scope", "path", "gates", "evidence"))
|
|
37
37
|
SKILL_DISCOVERY_SCOPES = frozenset(("project",))
|
|
38
|
-
SYNC_POLICY_KEYS = frozenset(("protectedFiles", "protectedPrefixes", "safeFiles", "safePrefixes", "managedBlockFiles", "obsoleteFiles"))
|
|
38
|
+
SYNC_POLICY_KEYS = frozenset(("protectedFiles", "forceProtectedFiles", "protectedPrefixes", "safeFiles", "safePrefixes", "managedBlockFiles", "obsoleteFiles"))
|
|
39
|
+
LOCALIZATION_KEYS = frozenset(("default", "supported", "assets"))
|
|
40
|
+
DEFAULT_LANGUAGE = "zh-CN"
|
|
41
|
+
SUPPORTED_LANGUAGES = ("zh-CN", "en-US")
|
|
39
42
|
COMMAND_TARGET_KEYS = frozenset(("config", "format", "target"))
|
|
40
43
|
COMMAND_TARGET_FORMATS = frozenset(("json", "toml", "yaml"))
|
|
41
44
|
CAPABILITY_DECLARATION_KEYS = frozenset(("status", "fallback"))
|
|
@@ -108,6 +111,7 @@ class HostPlatform:
|
|
|
108
111
|
@dataclass(frozen=True)
|
|
109
112
|
class SyncPolicy:
|
|
110
113
|
protected_files: tuple[str, ...]
|
|
114
|
+
force_protected_files: tuple[str, ...]
|
|
111
115
|
protected_prefixes: tuple[str, ...]
|
|
112
116
|
safe_files: tuple[str, ...]
|
|
113
117
|
safe_prefixes: tuple[str, ...]
|
|
@@ -115,6 +119,19 @@ class SyncPolicy:
|
|
|
115
119
|
obsolete_files: tuple[str, ...]
|
|
116
120
|
|
|
117
121
|
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class Localization:
|
|
124
|
+
"""Language-specific asset overrides.
|
|
125
|
+
|
|
126
|
+
The base template always carries the default language. A non-default
|
|
127
|
+
language may override a declared asset from `locales/<language>/`.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
default: str
|
|
131
|
+
supported: tuple[str, ...]
|
|
132
|
+
assets: dict[str, tuple[str, ...]]
|
|
133
|
+
|
|
134
|
+
|
|
118
135
|
@dataclass(frozen=True)
|
|
119
136
|
class HostManifest:
|
|
120
137
|
schema_version: int
|
|
@@ -123,6 +140,7 @@ class HostManifest:
|
|
|
123
140
|
capability_matrix: dict[str, dict[str, CapabilityDeclaration]]
|
|
124
141
|
platforms: tuple[HostPlatform, ...]
|
|
125
142
|
excluded_prefixes: tuple[str, ...]
|
|
143
|
+
localization: Localization
|
|
126
144
|
sync_policy: SyncPolicy
|
|
127
145
|
|
|
128
146
|
@property
|
|
@@ -195,6 +213,19 @@ def validate_host_assets(
|
|
|
195
213
|
if not (root / SCHEMA_PATH).is_file():
|
|
196
214
|
errors.append(f"missing host asset schema: {root / SCHEMA_PATH}")
|
|
197
215
|
|
|
216
|
+
# Locale sources live in the package template. An installed project keeps
|
|
217
|
+
# only the resolved canonical path, so absence of `locales/` is normal and
|
|
218
|
+
# must not turn into a host-asset error.
|
|
219
|
+
if (root / "locales").is_dir():
|
|
220
|
+
for language, assets in manifest.localization.assets.items():
|
|
221
|
+
for asset in assets:
|
|
222
|
+
if not (root / asset).is_file():
|
|
223
|
+
errors.append(f"missing localized base asset: {asset}")
|
|
224
|
+
if not (root / "locales" / language / asset).is_file():
|
|
225
|
+
errors.append(
|
|
226
|
+
f"missing localized asset: locales/{language}/{asset}"
|
|
227
|
+
)
|
|
228
|
+
|
|
198
229
|
selected_platforms: list[HostPlatform] = []
|
|
199
230
|
selected_ids = (
|
|
200
231
|
manifest.platform_ids
|
|
@@ -388,8 +419,13 @@ def _build_manifest(raw: dict[str, Any]) -> HostManifest:
|
|
|
388
419
|
capability_matrix=capability_matrix,
|
|
389
420
|
platforms=platforms,
|
|
390
421
|
excluded_prefixes=_string_tuple(raw.get("excludedPrefixes"), "excludedPrefixes"),
|
|
422
|
+
localization=_build_localization(raw.get("localization")),
|
|
391
423
|
sync_policy=SyncPolicy(
|
|
392
424
|
protected_files=_string_tuple(sync_raw.get("protectedFiles"), "syncPolicy.protectedFiles"),
|
|
425
|
+
force_protected_files=_string_tuple(
|
|
426
|
+
sync_raw.get("forceProtectedFiles", []),
|
|
427
|
+
"syncPolicy.forceProtectedFiles",
|
|
428
|
+
),
|
|
393
429
|
protected_prefixes=_string_tuple(sync_raw.get("protectedPrefixes"), "syncPolicy.protectedPrefixes"),
|
|
394
430
|
safe_files=_string_tuple(sync_raw.get("safeFiles"), "syncPolicy.safeFiles"),
|
|
395
431
|
safe_prefixes=_string_tuple(sync_raw.get("safePrefixes"), "syncPolicy.safePrefixes"),
|
|
@@ -398,6 +434,39 @@ def _build_manifest(raw: dict[str, Any]) -> HostManifest:
|
|
|
398
434
|
),
|
|
399
435
|
)
|
|
400
436
|
|
|
437
|
+
|
|
438
|
+
def _build_localization(raw: object) -> Localization:
|
|
439
|
+
if raw is None:
|
|
440
|
+
return Localization(
|
|
441
|
+
default=DEFAULT_LANGUAGE,
|
|
442
|
+
supported=SUPPORTED_LANGUAGES,
|
|
443
|
+
assets={},
|
|
444
|
+
)
|
|
445
|
+
if not isinstance(raw, dict):
|
|
446
|
+
raise HostManifestError("localization must be an object")
|
|
447
|
+
_reject_unknown_fields(raw, LOCALIZATION_KEYS, "localization")
|
|
448
|
+
default = raw.get("default")
|
|
449
|
+
if not isinstance(default, str) or not default.strip():
|
|
450
|
+
raise HostManifestError("localization.default must be a non-empty string")
|
|
451
|
+
supported = _string_tuple(raw.get("supported"), "localization.supported")
|
|
452
|
+
if default not in supported:
|
|
453
|
+
raise HostManifestError("localization.default must be listed in localization.supported")
|
|
454
|
+
assets_raw = raw.get("assets")
|
|
455
|
+
if not isinstance(assets_raw, dict):
|
|
456
|
+
raise HostManifestError("localization.assets must be an object")
|
|
457
|
+
assets: dict[str, tuple[str, ...]] = {}
|
|
458
|
+
for language, paths in assets_raw.items():
|
|
459
|
+
if language not in supported:
|
|
460
|
+
raise HostManifestError(
|
|
461
|
+
f"localization.assets language is not supported: {language}"
|
|
462
|
+
)
|
|
463
|
+
if language == default:
|
|
464
|
+
raise HostManifestError(
|
|
465
|
+
"localization.assets must not override the default language"
|
|
466
|
+
)
|
|
467
|
+
assets[language] = _string_tuple(paths, f"localization.assets.{language}")
|
|
468
|
+
return Localization(default=default, supported=supported, assets=assets)
|
|
469
|
+
|
|
401
470
|
def _build_capability_matrix(
|
|
402
471
|
raw: object,
|
|
403
472
|
allowed: tuple[str, ...],
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import re
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
from infra.paths import DIR_WORKFLOW, get_repo_root
|
|
@@ -18,9 +19,24 @@ DEFAULT_PARTY_MODE_V2_MAX_DRIFT_WARNINGS = 2
|
|
|
18
19
|
DEFAULT_PARTY_MODE_V2_FRESH_CONTEXT_PER_ROUND = True
|
|
19
20
|
DEFAULT_PARTY_MODE_V2_REQUIRE_CURRENT_ROUND_ONLY = True
|
|
20
21
|
|
|
22
|
+
DEFAULT_LANGUAGE = "zh-CN"
|
|
23
|
+
SUPPORTED_LANGUAGES = ("zh-CN", "en-US")
|
|
24
|
+
LANGUAGE_ALIASES = {
|
|
25
|
+
"zh": "zh-CN",
|
|
26
|
+
"zh-cn": "zh-CN",
|
|
27
|
+
"zh_cn": "zh-CN",
|
|
28
|
+
"en": "en-US",
|
|
29
|
+
"en-us": "en-US",
|
|
30
|
+
"en_us": "en-US",
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
CONFIG_FILE = "config.yaml"
|
|
22
34
|
|
|
23
35
|
|
|
36
|
+
class UnsupportedLanguageError(ValueError):
|
|
37
|
+
"""The config declares a language the runtime does not support."""
|
|
38
|
+
|
|
39
|
+
|
|
24
40
|
def _unquote(value: str) -> str:
|
|
25
41
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
26
42
|
return value[1:-1]
|
|
@@ -117,6 +133,75 @@ def _load_config(repo_root: Path | None = None) -> dict:
|
|
|
117
133
|
return {}
|
|
118
134
|
|
|
119
135
|
|
|
136
|
+
def normalize_language(value: object) -> str | None:
|
|
137
|
+
"""Canonical language for a supported value, otherwise None."""
|
|
138
|
+
if not isinstance(value, str):
|
|
139
|
+
return None
|
|
140
|
+
return LANGUAGE_ALIASES.get(value.strip().lower())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_LANGUAGE_KEY_RE = re.compile(r"^language\s*:\s*(.*)$")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_language_setting(
|
|
147
|
+
repo_root: Path | None = None,
|
|
148
|
+
) -> tuple[str | None, bool, bool]:
|
|
149
|
+
"""The explicit top-level language scalar.
|
|
150
|
+
|
|
151
|
+
Returns `(raw, present, duplicate)`. Only top-level `language:` lines count.
|
|
152
|
+
Two keys, an empty value, a glued comment, or trailing junk are left for
|
|
153
|
+
`normalize_language` to reject, matching the Node reader.
|
|
154
|
+
"""
|
|
155
|
+
config_file = _get_config_path(repo_root)
|
|
156
|
+
try:
|
|
157
|
+
content = config_file.read_text(encoding="utf-8")
|
|
158
|
+
except (OSError, IOError):
|
|
159
|
+
return None, False, False
|
|
160
|
+
|
|
161
|
+
entries: list[str] = []
|
|
162
|
+
for raw_line in content.splitlines():
|
|
163
|
+
if raw_line.startswith((" ", "\t")):
|
|
164
|
+
continue
|
|
165
|
+
match = _LANGUAGE_KEY_RE.match(raw_line)
|
|
166
|
+
if match:
|
|
167
|
+
entries.append(match.group(1))
|
|
168
|
+
if not entries:
|
|
169
|
+
return None, False, False
|
|
170
|
+
if len(entries) > 1:
|
|
171
|
+
return None, True, True
|
|
172
|
+
raw = _unquote(_strip_comment(entries[0].strip())).strip()
|
|
173
|
+
return raw, True, False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_language(
|
|
177
|
+
repo_root: Path | None = None,
|
|
178
|
+
*,
|
|
179
|
+
strict: bool = False,
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Task/plan artifact language from the project config.
|
|
182
|
+
|
|
183
|
+
A missing file or key yields the default. An explicit unsupported value or
|
|
184
|
+
duplicate `language` keys raises when strict is set; runtime generation uses
|
|
185
|
+
the non-strict form so a config typo cannot block task creation.
|
|
186
|
+
"""
|
|
187
|
+
raw, present, duplicate = _read_language_setting(repo_root)
|
|
188
|
+
if not present:
|
|
189
|
+
return DEFAULT_LANGUAGE
|
|
190
|
+
canonical = None if duplicate else normalize_language(raw)
|
|
191
|
+
if canonical is not None:
|
|
192
|
+
return canonical
|
|
193
|
+
if strict:
|
|
194
|
+
detail = (
|
|
195
|
+
"duplicate language keys"
|
|
196
|
+
if duplicate
|
|
197
|
+
else f"unsupported language {raw!r}"
|
|
198
|
+
)
|
|
199
|
+
raise UnsupportedLanguageError(
|
|
200
|
+
detail + "; expected one of: " + ", ".join(SUPPORTED_LANGUAGES)
|
|
201
|
+
)
|
|
202
|
+
return DEFAULT_LANGUAGE
|
|
203
|
+
|
|
204
|
+
|
|
120
205
|
|
|
121
206
|
def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
|
|
122
207
|
"""Hook commands declared for one lifecycle event; empty when none."""
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared decision-anchor heading schema.
|
|
3
|
+
|
|
4
|
+
The runtime recognizes two heading sets for the same sections: the historical
|
|
5
|
+
Chinese set and the English aliases introduced for English-language task
|
|
6
|
+
artifacts. Recognition is language-independent so existing tasks and mixed
|
|
7
|
+
language projects keep working; the configured project language only decides
|
|
8
|
+
which set a newly generated anchor uses.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
GOAL = "goal"
|
|
16
|
+
ACCEPTANCE = "acceptance"
|
|
17
|
+
REJECTED = "rejected"
|
|
18
|
+
ASSUMPTIONS = "assumptions"
|
|
19
|
+
SCOPE = "scope"
|
|
20
|
+
VERIFICATION = "verification"
|
|
21
|
+
|
|
22
|
+
REQUIRED_SECTIONS = (GOAL, ACCEPTANCE)
|
|
23
|
+
|
|
24
|
+
DEFAULT_LANGUAGE = "zh-CN"
|
|
25
|
+
|
|
26
|
+
LANGUAGE_HEADINGS: dict[str, dict[str, str]] = {
|
|
27
|
+
"zh-CN": {
|
|
28
|
+
GOAL: "目标",
|
|
29
|
+
ACCEPTANCE: "验收标准",
|
|
30
|
+
REJECTED: "被拒方案",
|
|
31
|
+
ASSUMPTIONS: "关键假设",
|
|
32
|
+
SCOPE: "范围边界",
|
|
33
|
+
VERIFICATION: "验证命令",
|
|
34
|
+
},
|
|
35
|
+
"en-US": {
|
|
36
|
+
GOAL: "Goal",
|
|
37
|
+
ACCEPTANCE: "Acceptance Criteria",
|
|
38
|
+
REJECTED: "Rejected Options",
|
|
39
|
+
ASSUMPTIONS: "Key Assumptions",
|
|
40
|
+
SCOPE: "Scope Boundary",
|
|
41
|
+
VERIFICATION: "Verification Commands",
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
SECTION_ALIASES: dict[str, str] = {
|
|
46
|
+
heading: section
|
|
47
|
+
for headings in LANGUAGE_HEADINGS.values()
|
|
48
|
+
for section, heading in headings.items()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
HEADING_RE = re.compile(r"^##\s*(.+?)\s*$")
|
|
52
|
+
ACCEPTANCE_RE = re.compile(
|
|
53
|
+
r"^\s*-\s*(?:\[([ xX])\]?\s*)?(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$"
|
|
54
|
+
)
|
|
55
|
+
LIST_ITEM_RE = re.compile(r"^\s*[-*]\s")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def canonical_section(heading: str) -> str | None:
|
|
59
|
+
"""Canonical section key for a recognized heading, or None."""
|
|
60
|
+
return SECTION_ALIASES.get(heading)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def headings_for(language: str) -> dict[str, str]:
|
|
64
|
+
"""Heading set for a language, falling back to the default language."""
|
|
65
|
+
return LANGUAGE_HEADINGS.get(language, LANGUAGE_HEADINGS[DEFAULT_LANGUAGE])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def heading_for(section: str, language: str) -> str:
|
|
69
|
+
"""One heading in the requested language, falling back to the default."""
|
|
70
|
+
return headings_for(language)[section]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def section_headings(section: str) -> tuple[str, ...]:
|
|
74
|
+
"""Every recognized heading for one section, default language first."""
|
|
75
|
+
return tuple(
|
|
76
|
+
LANGUAGE_HEADINGS[language][section] for language in LANGUAGE_HEADINGS
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def present_sections(text: str) -> set[str]:
|
|
81
|
+
"""Canonical keys of the recognized `##` sections present in the text."""
|
|
82
|
+
present: set[str] = set()
|
|
83
|
+
for raw in text.splitlines():
|
|
84
|
+
heading = HEADING_RE.match(raw.strip())
|
|
85
|
+
if not heading:
|
|
86
|
+
continue
|
|
87
|
+
section = canonical_section(heading.group(1))
|
|
88
|
+
if section is not None:
|
|
89
|
+
present.add(section)
|
|
90
|
+
return present
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def acceptance_section_lines(text: str) -> list[str]:
|
|
94
|
+
"""Raw lines under the acceptance heading, up to the next heading."""
|
|
95
|
+
lines: list[str] = []
|
|
96
|
+
inside = False
|
|
97
|
+
for raw in text.splitlines():
|
|
98
|
+
heading = HEADING_RE.match(raw.strip())
|
|
99
|
+
if heading:
|
|
100
|
+
inside = canonical_section(heading.group(1)) == ACCEPTANCE
|
|
101
|
+
continue
|
|
102
|
+
if inside:
|
|
103
|
+
lines.append(raw)
|
|
104
|
+
return lines
|
|
@@ -17,23 +17,30 @@ from datetime import datetime, timezone
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
|
+
from services.decision_anchor_schema import (
|
|
21
|
+
ACCEPTANCE,
|
|
22
|
+
ACCEPTANCE_RE,
|
|
23
|
+
GOAL,
|
|
24
|
+
HEADING_RE,
|
|
25
|
+
REJECTED,
|
|
26
|
+
SCOPE,
|
|
27
|
+
VERIFICATION,
|
|
28
|
+
canonical_section,
|
|
29
|
+
)
|
|
30
|
+
|
|
20
31
|
GOAL_TRUNCATE = 300
|
|
21
32
|
VERIFY_COMMAND_TRUNCATE = 120
|
|
22
33
|
VERIFY_COMMAND_MAX = 10
|
|
23
34
|
SCOPE_BOUNDARY_TRUNCATE = 160
|
|
24
|
-
ANCHOR_SECTIONS = ("目标", "验收标准", "被拒方案", "验证命令", "范围边界")
|
|
25
|
-
ACCEPTANCE_RE = re.compile(
|
|
26
|
-
r"^\s*-\s*(?:\[([ xX])\]?\s*)?(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$"
|
|
27
|
-
)
|
|
28
35
|
REJECTED_RE = re.compile(r"^\s*-\s*\*\*(.+?)\*\*")
|
|
29
|
-
_HEADING_RE = re.compile(r"^##\s*(.+?)\s*$")
|
|
30
36
|
|
|
31
37
|
|
|
32
38
|
def parse_decision_anchor(text: str) -> dict[str, Any]:
|
|
33
39
|
"""Extract goal / acceptance criteria / rejected option names / declared
|
|
34
40
|
verification commands / scope boundary from a decision-anchor.md body.
|
|
35
|
-
Section
|
|
36
|
-
line-level and degrades to
|
|
41
|
+
Section headings may be the Chinese set or the English aliases (see
|
|
42
|
+
spec/contracts/decision-anchor.md); parsing is line-level and degrades to
|
|
43
|
+
empty values when sections are missing."""
|
|
37
44
|
goal_lines: list[str] = []
|
|
38
45
|
acceptance: list[dict[str, str]] = []
|
|
39
46
|
rejected: list[str] = []
|
|
@@ -41,15 +48,14 @@ def parse_decision_anchor(text: str) -> dict[str, Any]:
|
|
|
41
48
|
scope_boundary = ""
|
|
42
49
|
section: str | None = None
|
|
43
50
|
for raw in text.splitlines():
|
|
44
|
-
heading =
|
|
51
|
+
heading = HEADING_RE.match(raw.strip())
|
|
45
52
|
if heading:
|
|
46
|
-
|
|
47
|
-
section = name if name in ANCHOR_SECTIONS else None
|
|
53
|
+
section = canonical_section(heading.group(1))
|
|
48
54
|
continue
|
|
49
|
-
if section ==
|
|
55
|
+
if section == GOAL:
|
|
50
56
|
if raw.strip():
|
|
51
57
|
goal_lines.append(raw.strip())
|
|
52
|
-
elif section ==
|
|
58
|
+
elif section == ACCEPTANCE:
|
|
53
59
|
match = ACCEPTANCE_RE.match(raw)
|
|
54
60
|
if match:
|
|
55
61
|
acceptance.append(
|
|
@@ -59,17 +65,17 @@ def parse_decision_anchor(text: str) -> dict[str, Any]:
|
|
|
59
65
|
"checked": match.group(1) in ("x", "X"),
|
|
60
66
|
}
|
|
61
67
|
)
|
|
62
|
-
elif section ==
|
|
68
|
+
elif section == REJECTED:
|
|
63
69
|
match = REJECTED_RE.match(raw)
|
|
64
70
|
if match:
|
|
65
71
|
rejected.append(match.group(1).strip())
|
|
66
|
-
elif section ==
|
|
72
|
+
elif section == VERIFICATION:
|
|
67
73
|
command = raw.strip()
|
|
68
74
|
if command.startswith("- "):
|
|
69
75
|
command = command[2:].strip()
|
|
70
76
|
if command and len(verify_commands) < VERIFY_COMMAND_MAX:
|
|
71
77
|
verify_commands.append(command[:VERIFY_COMMAND_TRUNCATE])
|
|
72
|
-
elif section ==
|
|
78
|
+
elif section == SCOPE:
|
|
73
79
|
if raw.strip() and not scope_boundary:
|
|
74
80
|
scope_boundary = raw.strip()[:SCOPE_BOUNDARY_TRUNCATE]
|
|
75
81
|
return {
|
|
@@ -32,6 +32,11 @@ from services.context_paths import (
|
|
|
32
32
|
normalize_context_path,
|
|
33
33
|
prepare_context_entry,
|
|
34
34
|
)
|
|
35
|
+
from services.decision_anchor_schema import (
|
|
36
|
+
REQUIRED_SECTIONS,
|
|
37
|
+
present_sections,
|
|
38
|
+
section_headings,
|
|
39
|
+
)
|
|
35
40
|
from services.plan_binding import normalize_plan_file, plan_file_blockers
|
|
36
41
|
from services.task_gate_profiles import (
|
|
37
42
|
GATE_PROFILE_STRICT,
|
|
@@ -360,13 +365,14 @@ class TaskContextService:
|
|
|
360
365
|
|
|
361
366
|
@staticmethod
|
|
362
367
|
def _decision_anchor_section_blockers(anchor_text: str) -> list[str]:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
368
|
+
present = present_sections(anchor_text)
|
|
369
|
+
return [
|
|
370
|
+
"decision-anchor.md missing required section: "
|
|
371
|
+
+ " (or ".join(f"## {heading}" for heading in section_headings(section))
|
|
372
|
+
+ ")"
|
|
373
|
+
for section in REQUIRED_SECTIONS
|
|
374
|
+
if section not in present
|
|
375
|
+
]
|
|
370
376
|
|
|
371
377
|
@staticmethod
|
|
372
378
|
def _is_tiny_task(task_data: dict) -> bool:
|