@my-life-buddies/cli 0.1.2

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.
Files changed (158) hide show
  1. package/README.md +314 -0
  2. package/dist/bin/buddy.js +443 -0
  3. package/dist/bin/buddy.js.map +7 -0
  4. package/dist/bin/core.js +5638 -0
  5. package/dist/bin/core.js.map +7 -0
  6. package/dist/bin/preview.js +1429 -0
  7. package/dist/bin/preview.js.map +7 -0
  8. package/dist/web/app.css +508 -0
  9. package/dist/web/app.js +724 -0
  10. package/dist/web/assets/developer-platform-icon.png.base64 +1 -0
  11. package/dist/web/brand.css +100 -0
  12. package/dist/web/index.html +236 -0
  13. package/dist/web/tokens.css +15 -0
  14. package/package.json +45 -0
  15. package/resources/README.md +52 -0
  16. package/resources/agent-template/package-lock.json +1726 -0
  17. package/resources/agent-template/package.json +16 -0
  18. package/resources/brand/README.md +11 -0
  19. package/resources/brand/login-result.css +25 -0
  20. package/resources/brand/logo.png +0 -0
  21. package/resources/brand/tokens.css +100 -0
  22. package/resources/buddy-creator/INSTALL.md +25 -0
  23. package/resources/buddy-creator/PATCHES.md +33 -0
  24. package/resources/buddy-creator/SKILL.md +66 -0
  25. package/resources/buddy-creator/THIRD_PARTY_NOTICES.md +154 -0
  26. package/resources/buddy-creator/agents/openai.yaml +4 -0
  27. package/resources/buddy-creator/assets/licenses/bail.txt +26 -0
  28. package/resources/buddy-creator/assets/licenses/ccount.txt +26 -0
  29. package/resources/buddy-creator/assets/licenses/character-entities-html4.txt +26 -0
  30. package/resources/buddy-creator/assets/licenses/character-entities-legacy.txt +26 -0
  31. package/resources/buddy-creator/assets/licenses/character-entities.txt +26 -0
  32. package/resources/buddy-creator/assets/licenses/character-reference-invalid.txt +26 -0
  33. package/resources/buddy-creator/assets/licenses/comma-separated-tokens.txt +26 -0
  34. package/resources/buddy-creator/assets/licenses/debug.txt +24 -0
  35. package/resources/buddy-creator/assets/licenses/decode-named-character-reference.txt +26 -0
  36. package/resources/buddy-creator/assets/licenses/dequal.txt +25 -0
  37. package/resources/buddy-creator/assets/licenses/devlop.txt +26 -0
  38. package/resources/buddy-creator/assets/licenses/escape-string-regexp.txt +13 -0
  39. package/resources/buddy-creator/assets/licenses/estree-util-is-identifier-name.txt +26 -0
  40. package/resources/buddy-creator/assets/licenses/extend.txt +27 -0
  41. package/resources/buddy-creator/assets/licenses/hast-util-to-jsx-runtime.txt +26 -0
  42. package/resources/buddy-creator/assets/licenses/hast-util-whitespace.txt +26 -0
  43. package/resources/buddy-creator/assets/licenses/html-url-attributes.txt +25 -0
  44. package/resources/buddy-creator/assets/licenses/inline-style-parser.txt +13 -0
  45. package/resources/buddy-creator/assets/licenses/is-alphabetical.txt +26 -0
  46. package/resources/buddy-creator/assets/licenses/is-alphanumerical.txt +26 -0
  47. package/resources/buddy-creator/assets/licenses/is-decimal.txt +26 -0
  48. package/resources/buddy-creator/assets/licenses/is-hexadecimal.txt +26 -0
  49. package/resources/buddy-creator/assets/licenses/is-plain-obj.txt +13 -0
  50. package/resources/buddy-creator/assets/licenses/longest-streak.txt +26 -0
  51. package/resources/buddy-creator/assets/licenses/markdown-table.txt +26 -0
  52. package/resources/buddy-creator/assets/licenses/mdast-util-find-and-replace.txt +26 -0
  53. package/resources/buddy-creator/assets/licenses/mdast-util-from-markdown.txt +26 -0
  54. package/resources/buddy-creator/assets/licenses/mdast-util-gfm-autolink-literal.txt +26 -0
  55. package/resources/buddy-creator/assets/licenses/mdast-util-gfm-footnote.txt +26 -0
  56. package/resources/buddy-creator/assets/licenses/mdast-util-gfm-strikethrough.txt +26 -0
  57. package/resources/buddy-creator/assets/licenses/mdast-util-gfm-table.txt +26 -0
  58. package/resources/buddy-creator/assets/licenses/mdast-util-gfm-task-list-item.txt +26 -0
  59. package/resources/buddy-creator/assets/licenses/mdast-util-gfm.txt +26 -0
  60. package/resources/buddy-creator/assets/licenses/mdast-util-mdx-expression.txt +26 -0
  61. package/resources/buddy-creator/assets/licenses/mdast-util-mdx-jsx.txt +26 -0
  62. package/resources/buddy-creator/assets/licenses/mdast-util-mdxjs-esm.txt +26 -0
  63. package/resources/buddy-creator/assets/licenses/mdast-util-phrasing.txt +27 -0
  64. package/resources/buddy-creator/assets/licenses/mdast-util-to-hast.txt +26 -0
  65. package/resources/buddy-creator/assets/licenses/mdast-util-to-markdown.txt +26 -0
  66. package/resources/buddy-creator/assets/licenses/mdast-util-to-string.txt +26 -0
  67. package/resources/buddy-creator/assets/licenses/micromark-core-commonmark.txt +26 -0
  68. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-autolink-literal.txt +26 -0
  69. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-footnote.txt +26 -0
  70. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-strikethrough.txt +26 -0
  71. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-table.txt +26 -0
  72. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-tagfilter.txt +26 -0
  73. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-task-list-item.txt +26 -0
  74. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm.txt +26 -0
  75. package/resources/buddy-creator/assets/licenses/micromark-factory-destination.txt +26 -0
  76. package/resources/buddy-creator/assets/licenses/micromark-factory-label.txt +26 -0
  77. package/resources/buddy-creator/assets/licenses/micromark-factory-space.txt +26 -0
  78. package/resources/buddy-creator/assets/licenses/micromark-factory-title.txt +26 -0
  79. package/resources/buddy-creator/assets/licenses/micromark-factory-whitespace.txt +26 -0
  80. package/resources/buddy-creator/assets/licenses/micromark-util-character.txt +26 -0
  81. package/resources/buddy-creator/assets/licenses/micromark-util-chunked.txt +26 -0
  82. package/resources/buddy-creator/assets/licenses/micromark-util-classify-character.txt +26 -0
  83. package/resources/buddy-creator/assets/licenses/micromark-util-combine-extensions.txt +26 -0
  84. package/resources/buddy-creator/assets/licenses/micromark-util-decode-numeric-character-reference.txt +26 -0
  85. package/resources/buddy-creator/assets/licenses/micromark-util-decode-string.txt +26 -0
  86. package/resources/buddy-creator/assets/licenses/micromark-util-encode.txt +26 -0
  87. package/resources/buddy-creator/assets/licenses/micromark-util-html-tag-name.txt +26 -0
  88. package/resources/buddy-creator/assets/licenses/micromark-util-normalize-identifier.txt +26 -0
  89. package/resources/buddy-creator/assets/licenses/micromark-util-resolve-all.txt +26 -0
  90. package/resources/buddy-creator/assets/licenses/micromark-util-sanitize-uri.txt +26 -0
  91. package/resources/buddy-creator/assets/licenses/micromark-util-subtokenize.txt +26 -0
  92. package/resources/buddy-creator/assets/licenses/micromark-util-symbol.txt +26 -0
  93. package/resources/buddy-creator/assets/licenses/micromark-util-types.txt +26 -0
  94. package/resources/buddy-creator/assets/licenses/micromark.txt +26 -0
  95. package/resources/buddy-creator/assets/licenses/ms.txt +25 -0
  96. package/resources/buddy-creator/assets/licenses/parse-entities.txt +26 -0
  97. package/resources/buddy-creator/assets/licenses/phosphor-icons--react.txt +25 -0
  98. package/resources/buddy-creator/assets/licenses/property-information.txt +26 -0
  99. package/resources/buddy-creator/assets/licenses/react-dom.txt +25 -0
  100. package/resources/buddy-creator/assets/licenses/react-markdown.txt +25 -0
  101. package/resources/buddy-creator/assets/licenses/react.txt +25 -0
  102. package/resources/buddy-creator/assets/licenses/remark-gfm.txt +26 -0
  103. package/resources/buddy-creator/assets/licenses/remark-parse.txt +25 -0
  104. package/resources/buddy-creator/assets/licenses/remark-rehype.txt +26 -0
  105. package/resources/buddy-creator/assets/licenses/remark-stringify.txt +25 -0
  106. package/resources/buddy-creator/assets/licenses/scheduler.txt +25 -0
  107. package/resources/buddy-creator/assets/licenses/space-separated-tokens.txt +26 -0
  108. package/resources/buddy-creator/assets/licenses/stringify-entities.txt +26 -0
  109. package/resources/buddy-creator/assets/licenses/style-to-js.txt +26 -0
  110. package/resources/buddy-creator/assets/licenses/style-to-object.txt +26 -0
  111. package/resources/buddy-creator/assets/licenses/trim-lines.txt +26 -0
  112. package/resources/buddy-creator/assets/licenses/trough.txt +25 -0
  113. package/resources/buddy-creator/assets/licenses/types--debug.txt +25 -0
  114. package/resources/buddy-creator/assets/licenses/types--estree-jsx.txt +25 -0
  115. package/resources/buddy-creator/assets/licenses/types--estree.txt +25 -0
  116. package/resources/buddy-creator/assets/licenses/types--hast.txt +25 -0
  117. package/resources/buddy-creator/assets/licenses/types--mdast.txt +25 -0
  118. package/resources/buddy-creator/assets/licenses/types--ms.txt +25 -0
  119. package/resources/buddy-creator/assets/licenses/types--unist.txt +25 -0
  120. package/resources/buddy-creator/assets/licenses/ungap--structured-clone.txt +19 -0
  121. package/resources/buddy-creator/assets/licenses/unified.txt +25 -0
  122. package/resources/buddy-creator/assets/licenses/unist-util-is.txt +26 -0
  123. package/resources/buddy-creator/assets/licenses/unist-util-position.txt +26 -0
  124. package/resources/buddy-creator/assets/licenses/unist-util-stringify-position.txt +26 -0
  125. package/resources/buddy-creator/assets/licenses/unist-util-visit-parents.txt +26 -0
  126. package/resources/buddy-creator/assets/licenses/unist-util-visit.txt +26 -0
  127. package/resources/buddy-creator/assets/licenses/vfile-message.txt +26 -0
  128. package/resources/buddy-creator/assets/licenses/vfile.txt +25 -0
  129. package/resources/buddy-creator/assets/licenses/zwitch.txt +26 -0
  130. package/resources/buddy-creator/assets/preview/assets/index-B5VmkoSx.js +39 -0
  131. package/resources/buddy-creator/assets/preview/assets/index-DWKP6b-3.css +4 -0
  132. package/resources/buddy-creator/assets/preview/brand-logo.png +0 -0
  133. package/resources/buddy-creator/assets/preview/brand.css +100 -0
  134. package/resources/buddy-creator/assets/preview/index.html +5 -0
  135. package/resources/buddy-creator/assets/preview/reader.css +165 -0
  136. package/resources/buddy-creator/assets/preview/reader.js +135 -0
  137. package/resources/buddy-creator/references/artifact-schema.md +68 -0
  138. package/resources/buddy-creator/references/catalog.json +401 -0
  139. package/resources/buddy-creator/references/definition.md +24 -0
  140. package/resources/buddy-creator/references/dialogue-examples.md +133 -0
  141. package/resources/buddy-creator/references/host-guide.md +213 -0
  142. package/resources/buddy-creator/references/interview.md +235 -0
  143. package/resources/buddy-creator/references/knowledge.md +40 -0
  144. package/resources/buddy-creator/references/methods.md +77 -0
  145. package/resources/buddy-creator/references/opening.md +14 -0
  146. package/resources/buddy-creator/references/preview-panel.md +66 -0
  147. package/resources/buddy-creator/references/recovery.md +43 -0
  148. package/resources/buddy-creator/references/service.md +74 -0
  149. package/resources/buddy-creator/scripts/buddy.py +108 -0
  150. package/resources/buddy-creator/scripts/buddy_client.py +67 -0
  151. package/resources/buddy-creator/scripts/buddy_core.py +803 -0
  152. package/resources/buddy-creator/scripts/completion.py +240 -0
  153. package/resources/buddy-creator/scripts/interview.py +215 -0
  154. package/resources/buddy-creator/scripts/preview.py +577 -0
  155. package/resources/buddy-creator/scripts/preview_panel.py +80 -0
  156. package/resources/buddy-creator/scripts/sources.py +239 -0
  157. package/resources/buddy-creator/version.json +8 -0
  158. package/resources/buddy-creator.manifest.json +696 -0
@@ -0,0 +1,240 @@
1
+ """Create local deliverables from confirmed versions. No developer handoff."""
2
+ import hashlib
3
+ import copy
4
+ import html
5
+ import json
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ LABELS = {'definition': '定义', 'knowledge': '知识', 'methods': '方法', 'service': '服务'}
13
+
14
+
15
+ def _bytes(value):
16
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False).encode('utf-8')
17
+
18
+
19
+ def _hash(raw):
20
+ return hashlib.sha256(raw).hexdigest()
21
+
22
+
23
+ def _atomic(path, raw):
24
+ path = Path(path)
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ fd, temporary = tempfile.mkstemp(prefix='.' + path.name + '-', dir=str(path.parent))
27
+ try:
28
+ with os.fdopen(fd, 'wb') as stream:
29
+ stream.write(raw)
30
+ stream.flush()
31
+ os.fsync(stream.fileno())
32
+ os.replace(temporary, str(path))
33
+ finally:
34
+ if os.path.exists(temporary):
35
+ os.unlink(temporary)
36
+
37
+
38
+ def _confirmed(state, object_id):
39
+ artifact = state.get('artifacts', {}).get(object_id)
40
+ return bool(artifact and any(record.get('objectId') == object_id and record.get('hash') == artifact['hash']
41
+ and not record.get('invalidatedBy') and record.get('decision') == 'confirmed'
42
+ for record in state.get('confirmations', [])))
43
+
44
+
45
+ def _ready(state, catalog):
46
+ return not state.get('paused') and not state.get('pendingTurnId') and all(
47
+ _confirmed(state, '%s.%d' % (stage, index + 1))
48
+ for stage in catalog['stages'] for index in range(len(catalog['chapters'][stage])))
49
+
50
+
51
+ def _line_chunks(text, columns=32, limit=3):
52
+ text = ' '.join(str(text or '待补充').split())
53
+ lines, line, width = [], '', 0
54
+ for char in text:
55
+ char_width = 1 if ord(char) < 128 else 2
56
+ if width + char_width > columns * 2:
57
+ lines.append(line)
58
+ line, width = '', 0
59
+ line += char
60
+ width += char_width
61
+ if line:
62
+ lines.append(line)
63
+ if len(lines) > limit:
64
+ lines = lines[:limit]
65
+ lines[-1] = lines[-1][:-1] + '…'
66
+ return lines
67
+
68
+
69
+ def _blueprint(state):
70
+ blueprint = copy.deepcopy(state['artifacts'].get('service.blueprint', {}).get('data') or {})
71
+ transitions = blueprint.setdefault('transitions', {})
72
+ for artifact in state['artifacts'].values():
73
+ if artifact.get('kind') == 'transition' and artifact.get('data'):
74
+ path_id = artifact['id'].removeprefix('transition.')
75
+ transitions[path_id] = dict(transitions.get(path_id, {}), **artifact['data'])
76
+ return blueprint
77
+
78
+
79
+ def _diagram(state):
80
+ blueprint = _blueprint(state)
81
+ stages = blueprint.get('stages', {})
82
+ transitions = blueprint.get('transitions', {})
83
+ boxes = []
84
+ for key, title, meaning, y in [('acquisition', '获客期', '先免费体验', 90),
85
+ ('paid', '付费期', '订阅后持续获得帮助 · AI 对话不限次数', 325),
86
+ ('maintenance', '维持期', '未订阅或到期未续费 · 保留基础对话与历史', 560)]:
87
+ config = stages.get(key, {})
88
+ lines = _line_chunks(config.get('result') or config.get('service'), columns=32, limit=3)
89
+ content = ''.join('<tspan x="76" dy="25">%s</tspan>' % html.escape(line) for line in lines)
90
+ boxes.append('<g><rect x="48" y="%d" width="540" height="160" rx="12" fill="#20271b" stroke="#556446"/>'
91
+ '<text x="76" y="%d" fill="#b8d879" font-size="24" font-weight="700">%s</text>'
92
+ '<text x="76" y="%d" fill="#bac2b0" font-size="14">%s</text>'
93
+ '<text y="%d" fill="#edf0e8" font-size="17">%s</text></g>' %
94
+ (y, y + 35, title, y + 61, html.escape(meaning), y + 71, content))
95
+ cycle = blueprint.get('billingCycle') or {}
96
+ units = {'day': '天', 'week': '周', 'month': '个月', 'year': '年'}
97
+ duration = str(cycle.get('count', '')) + units.get(cycle.get('unit'), '') if isinstance(cycle, dict) else str(cycle)
98
+ terms = ('订阅:¥%s / %s' % (blueprint.get('price', '待定'), duration or '待定'))
99
+ def caption(key, fallback):
100
+ return html.escape(str(transitions.get(key, {}).get('title') or fallback))
101
+ return ('<svg xmlns="http://www.w3.org/2000/svg" width="980" height="810" viewBox="0 0 980 810" role="img" aria-labelledby="title desc">'
102
+ '<title id="title">Buddy 服务模式</title><desc id="desc">免费体验、付费订阅和维持三阶段,以及五条用户路径。完整约定见服务手册。</desc>'
103
+ '<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">'
104
+ '<path d="M0 0L10 5L0 10Z" fill="#b8d879"/></marker></defs>'
105
+ '<rect width="980" height="810" rx="16" fill="#151a13"/><g font-family="system-ui,sans-serif">'
106
+ '<text x="48" y="43" fill="#edf0e8" font-size="25" font-weight="700">Buddy · 服务模式</text>'
107
+ '<text x="48" y="69" fill="#bac2b0" font-size="15">%s</text>%s'
108
+ '<g fill="none" stroke="#94aa76" stroke-width="2" marker-end="url(#arrow)">'
109
+ '<path d="M260 250V325"/><path d="M260 485V560"/>'
110
+ '<path d="M588 150H902V640H588"/>'
111
+ '<path d="M588 355H682V445H588"/>'
112
+ '<path d="M588 670H780V410H588" stroke-dasharray="6 5"/></g>'
113
+ '<g fill="#dce8ca" font-size="16">'
114
+ '<text x="278" y="292">%s</text><text x="278" y="527">%s</text>'
115
+ '<text x="808" y="284">%s</text><text x="693" y="390">%s</text>'
116
+ '<text x="793" y="555">%s</text></g>'
117
+ '<text x="48" y="769" fill="#bac2b0" font-size="15">阶段卡片展示摘要;具体服务、权益和转换条件以已确认手册为准。</text></g></svg>') % (
118
+ html.escape(terms), ''.join(boxes), caption('acquisition-paid', '体验后订阅'),
119
+ caption('paid-maintenance', '到期未续费'), caption('acquisition-maintenance', '暂不订阅'),
120
+ caption('paid-paid', '续费'), caption('maintenance-paid', '恢复订阅'))
121
+
122
+
123
+ def snapshot(workspace, state):
124
+ path = Path(workspace) / 'completion.json'
125
+ if not path.exists() or state.get('pendingTurnId') or state.get('paused'):
126
+ return None
127
+ try:
128
+ record = json.loads(path.read_text(encoding='utf-8'))
129
+ if record.get('revision') != state.get('revision'):
130
+ return None
131
+ if record.get('status') == 'ready' and not (Path(record['directory']) / 'BUDDY_MANUAL.md').is_file():
132
+ record['status'] = 'failed'
133
+ record['message'] = '四册确认已保留,本地成果需要重新生成。'
134
+ return {key: record.get(key) for key in ('revision', 'status', 'message', 'fileCount', 'updatedAt')}
135
+ except (OSError, ValueError, KeyError):
136
+ return None
137
+
138
+
139
+ def return_to_caller(workspace, state, result):
140
+ """Report the Skill boundary, not the caller's next implementation step. Read-only."""
141
+ catalog = json.loads((Path(__file__).resolve().parent.parent / 'references' / 'catalog.json').read_text(encoding='utf-8'))
142
+ if not _ready(state, catalog):
143
+ return result
144
+ workspace = Path(workspace).resolve()
145
+ record = None
146
+ try:
147
+ record = json.loads((workspace / 'completion.json').read_text(encoding='utf-8'))
148
+ revision = state['revision']
149
+ if not isinstance(revision, str) or not revision or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-' for c in revision):
150
+ raise ValueError('版本身份无效。')
151
+ if not isinstance(record, dict) or record.get('revision') != revision or record.get('status') != 'ready':
152
+ raise ValueError('当前确认版本的成果尚未生成成功。')
153
+ directory = workspace / 'deliverables' / revision
154
+ manual, manifest = directory / 'BUDDY_MANUAL.md', directory / 'MANIFEST.json'
155
+ if (directory.parent.is_symlink() or directory.is_symlink() or manual.is_symlink() or manifest.is_symlink()
156
+ or record.get('directory') != str(directory) or record.get('manualPath') != str(manual)):
157
+ raise ValueError('成果路径与当前作品不一致。')
158
+ inventory = json.loads(manifest.read_text(encoding='utf-8'))
159
+ entries = [f for f in inventory['files'] if f['path'] == 'BUDDY_MANUAL.md']
160
+ if (inventory.get('revision') != revision or inventory.get('buddyId') != state['buddyId']
161
+ or inventory.get('complete') is not True or len(entries) != 1
162
+ or _hash(manual.read_bytes()) != entries[0]['sha256']):
163
+ raise ValueError('手册与已确认成果不一致。')
164
+ result['completion'] = {key: record[key] for key in
165
+ ('revision', 'status', 'directory', 'manualPath', 'fileCount', 'message', 'updatedAt')}
166
+ result['directive'] = 'creator_complete'
167
+ except (OSError, ValueError, KeyError, TypeError) as error:
168
+ # Never hand off a stale path or a success-shaped but unusable export.
169
+ result['completion'] = {'revision': state.get('revision'), 'status': 'failed',
170
+ 'message': '四册确认已保留,当前成果缺失、失效或被修改;先用 finalize 恢复交付。',
171
+ 'error': (record.get('error') if isinstance(record, dict)
172
+ and record.get('revision') == state['revision'] else None) or str(error)}
173
+ result['directive'] = 'finalize_required'
174
+ return result
175
+
176
+
177
+ def finalize(workspace, state):
178
+ workspace = Path(workspace)
179
+ catalog = json.loads((Path(__file__).resolve().parent.parent / 'references' / 'catalog.json').read_text(encoding='utf-8'))
180
+ if not _ready(state, catalog):
181
+ return None
182
+ revision = str(state['revision'])
183
+ if not revision or any(char not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-' for char in revision):
184
+ raise ValueError('REVISION_INVALID: 无法识别当前版本身份。')
185
+ output = workspace / 'deliverables' / revision
186
+ record = {'revision': revision, 'updatedAt': datetime.now(timezone.utc).isoformat()}
187
+ staging = None
188
+ try:
189
+ files, manual = {}, ['# Buddy 创作手册', '', '搭子:' + state['buddyId'], '']
190
+ for stage in catalog['stages']:
191
+ parts = ['# ' + LABELS[stage] + '手册', '']
192
+ for index, title in enumerate(catalog['chapters'][stage]):
193
+ artifact = state['artifacts']['%s.%d' % (stage, index + 1)]
194
+ parts.extend(['## %d. %s' % (index + 1, title), '', artifact['markdown'], ''])
195
+ if artifact.get('unresolved'):
196
+ parts.extend(['待补充:' + ';'.join(artifact['unresolved']), ''])
197
+ text = '\n'.join(parts)
198
+ files['booklets/%s.md' % stage] = text.encode('utf-8')
199
+ manual.extend([text, ''])
200
+ manual.extend(['## 服务模式图', '', '![服务模式](SERVICE_MODEL.svg)', '',
201
+ '本手册记录已确认的创作设计,后续开发、内容核实和实际履约仍按具体项目推进。', ''])
202
+ files['BUDDY_MANUAL.md'] = '\n'.join(manual).encode('utf-8')
203
+ files['SERVICE_MODEL.svg'] = _diagram(state).encode('utf-8')
204
+ files['service-blueprint.json'] = _bytes({
205
+ 'data': _blueprint(state),
206
+ 'sourceArtifact': {key: state['artifacts'].get('service.blueprint', {}).get(key) for key in ('id', 'hash')},
207
+ 'transitionArtifacts': [{key: artifact.get(key) for key in ('id', 'hash')} for artifact in state['artifacts'].values() if artifact.get('kind') == 'transition']})
208
+ files['versions.json'] = _bytes({'buddyId': state['buddyId'], 'revision': revision,
209
+ 'artifacts': state['artifacts'], 'confirmations': state['confirmations']})
210
+ files['sources.json'] = _bytes(list(state.get('sources', {}).values()))
211
+ files['MANIFEST.json'] = _bytes({'format': 'buddy-creator-1', 'buddyId': state['buddyId'],
212
+ 'revision': revision, 'complete': True,
213
+ 'files': [{'path': path, 'sha256': _hash(raw), 'bytes': len(raw)} for path, raw in files.items()]})
214
+ output.parent.mkdir(parents=True, exist_ok=True)
215
+ staging = Path(tempfile.mkdtemp(prefix='.build-', dir=str(output.parent)))
216
+ for path, raw in files.items():
217
+ _atomic(staging / path, raw)
218
+ if output.exists():
219
+ # Reuse intact outputs; preserve manually edited exports as a separate directory.
220
+ intact = all((output / path).is_file() and (output / path).read_bytes() == raw for path, raw in files.items())
221
+ if intact:
222
+ shutil.rmtree(str(staging))
223
+ staging = None
224
+ else:
225
+ saved = output.with_name(output.name + '-previous-' + datetime.now().strftime('%Y%m%d%H%M%S%f'))
226
+ os.replace(str(output), str(saved))
227
+ os.replace(str(staging), str(output))
228
+ staging = None
229
+ else:
230
+ os.replace(str(staging), str(output))
231
+ staging = None
232
+ record.update(status='ready', directory=str(output), manualPath=str(output / 'BUDDY_MANUAL.md'),
233
+ fileCount=len(files), message='四册已确认,创作手册和服务模式图已保存在本机。')
234
+ except Exception as error:
235
+ record.update(status='failed', error=str(error), message='四册确认已保留,本地成果生成需要重试。')
236
+ finally:
237
+ if staging:
238
+ shutil.rmtree(str(staging), ignore_errors=True)
239
+ _atomic(workspace / 'completion.json', _bytes(record))
240
+ return record
@@ -0,0 +1,215 @@
1
+ """Bound interview depth by both a concrete gap and its parent target.
2
+
3
+ The host assesses meaning and novelty with evidence; code owns counters, bounds
4
+ and history. No model calls and no keyword-based inference of user consent.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import re
10
+
11
+ from buddy_core import CLOSED, catalog, evidence, require, text
12
+
13
+ POLICY = catalog()['interviewPolicy']
14
+ GAP_LIMIT = POLICY['gapMaxAnswers']
15
+ NO_GAIN_LIMIT = POLICY['noGainLimit']
16
+
17
+ def tracked(target_id):
18
+ # Source discovery still ends by the creator's explicit choice.
19
+ return target_id != 'K02'
20
+
21
+
22
+ def initial_id(target_id):
23
+ return target_id + '.initial'
24
+
25
+
26
+ def ensure(state, target_id):
27
+ history = state.setdefault('interview', {'targets': {}, 'gaps': {}})
28
+ if target_id not in history['targets']:
29
+ answers = state['targets'][target_id]['answerInputIds']
30
+ history['targets'][target_id] = {
31
+ 'windowStart': 0, 'noGainStreak': 0, 'lastGain': False,
32
+ 'stopReason': None, 'reopens': [],
33
+ }
34
+ history['gaps'][initial_id(target_id)] = {
35
+ 'id': initial_id(target_id), 'targetId': target_id,
36
+ 'description': catalog()['cards'][target_id]['minimum'],
37
+ 'impact': '当前目标的必要内容', 'blocking': catalog()['cards'][target_id]['hard'], 'evidence': [],
38
+ 'status': 'open', 'windowStart': 0, 'noGainStreak': 0,
39
+ 'answers': [{'inputId': key, 'hasNewInformation': None} for key in answers],
40
+ }
41
+ return history['targets'][target_id]
42
+
43
+
44
+ def count(state, target_id):
45
+ progress = state.get('interview', {}).get('targets', {}).get(target_id, {})
46
+ return len(state['targets'][target_id]['answerInputIds']) - progress.get('windowStart', 0)
47
+
48
+
49
+ def target_open(state, target_id):
50
+ card = catalog()['cards'][target_id]
51
+ require(count(state, target_id) < card['maxAnswers'], 'TARGET_LIMIT',
52
+ '本目标已到本轮回答上限;保留待补,转到其他目标。更换缺口或案例不会重置次数。', {'targetId': target_id})
53
+ progress = state.get('interview', {}).get('targets', {}).get(target_id, {})
54
+ require(not progress.get('stopReason'), 'TARGET_DEFERRED',
55
+ '本轮已收口;只能依据用户明确针对该目标的续谈请求重新展开。', {'targetId': target_id})
56
+ review = card.get('reviewAfter', card['maxAnswers'])
57
+ if count(state, target_id) >= review:
58
+ require(progress.get('lastGain') is True, 'NO_EXTENSION',
59
+ '整理节点后没有实质新增,不能继续延长本目标。', {'targetId': target_id})
60
+
61
+
62
+ def question_gap(state, question):
63
+ target_id = question['targetId']
64
+ ensure(state, target_id)
65
+ gap_id = question.get('gapId', initial_id(target_id))
66
+ gap = state['interview']['gaps'].get(gap_id)
67
+ require(gap and gap['targetId'] == target_id, 'GAP_NOT_FOUND',
68
+ '问题必须绑定本目标已登记的具体缺口。', {'gapId': gap_id})
69
+ require(gap['status'] == 'open' and len(gap['answers']) - gap['windowStart'] < GAP_LIMIT
70
+ and gap['noGainStreak'] < NO_GAIN_LIMIT, 'GAP_CLOSED',
71
+ '该缺口已讲清、暂放或达到边界;不要换一种问法重复追问。', {'gapId': gap_id})
72
+ card = catalog()['cards'][target_id]
73
+ if count(state, target_id) >= card.get('reviewAfter', card['maxAnswers']):
74
+ require(gap['blocking'], 'EXTENSION_REQUIRES_BLOCKER',
75
+ '四轮整理后只补影响当前结论或执行的关键缺口,其他细节留待补充。')
76
+ return {'targetId': target_id, 'gapId': gap_id}
77
+
78
+
79
+ def prepare(state, patch, inp, intent):
80
+ require(isinstance(patch, dict) and not set(patch) - {'gaps', 'assessment', 'reopen', 'resolutions'},
81
+ 'INTERVIEW_SCHEMA', 'interview 仅使用 gaps、assessment、reopen、resolutions。')
82
+ changed = set()
83
+ proposals = patch.get('gaps', [])
84
+ require(isinstance(proposals, list), 'GAP_SCHEMA', 'gaps 使用数组。')
85
+ for proposal in proposals:
86
+ require(isinstance(proposal, dict) and set(proposal) ==
87
+ {'id', 'targetId', 'description', 'impact', 'blocking', 'evidence'},
88
+ 'GAP_SCHEMA', '缺口使用 id/targetId/description/impact/blocking/evidence。')
89
+ key, target_id = proposal['id'], proposal['targetId']
90
+ require(target_id in state['targets'] and tracked(target_id), 'GAP_TARGET', '缺口必须属于现有采访目标。')
91
+ require(catalog()['cards'][target_id]['stage'] == state['stage'], 'STAGE_SCOPE', '只登记当前阶段的采访缺口。')
92
+ require(text(key) and re.fullmatch(re.escape(target_id) + r'\.[a-z0-9_-]{1,48}', key)
93
+ and text(proposal['description']) and text(proposal['impact'])
94
+ and type(proposal['blocking']) is bool, 'GAP_SCHEMA', '缺口须有稳定编号、具体内容和对本目标的影响。')
95
+ evidence(state, proposal['evidence'])
96
+ require(proposal['evidence'], 'EVIDENCE_REQUIRED', '新增缺口必须有真实原话、资料或产物依据。')
97
+ ensure(state, target_id)
98
+ gaps = state['interview']['gaps']
99
+ old = gaps.get(key)
100
+ if old:
101
+ require(all(old[field] == proposal[field] for field in proposal), 'GAP_IMMUTABLE',
102
+ '沿用已有缺口编号和含义,不能改写它以重置采访。')
103
+ continue
104
+ target_open(state, target_id)
105
+ require(state['targets'][target_id]['status'] not in CLOSED, 'FOLLOWUP_CLOSED', '目标已收敛,不能靠新增缺口重开。')
106
+ normalized = re.sub(r'\s+', '', proposal['description'])
107
+ require(not any(g['targetId'] == target_id and re.sub(r'\s+', '', g['description']) == normalized
108
+ for g in gaps.values()), 'DUPLICATE_GAP', '相同缺口沿用原编号。')
109
+ gaps[key] = dict(copy.deepcopy(proposal), status='open', windowStart=0, noGainStreak=0, answers=[])
110
+ changed.add(target_id)
111
+ resolutions = patch.get('resolutions', [])
112
+ require(isinstance(resolutions, list), 'RESOLUTION_SCHEMA', 'resolutions 使用数组。')
113
+ for resolution in resolutions:
114
+ require(isinstance(resolution, dict) and set(resolution) == {'gapId', 'summary', 'evidence'}
115
+ and text(resolution['summary']), 'RESOLUTION_SCHEMA', '补充解决使用 gapId/summary/evidence。')
116
+ evidence(state, resolution['evidence'], inp['id'])
117
+ gap = state.get('interview', {}).get('gaps', {}).get(resolution['gapId'])
118
+ require(gap, 'GAP_NOT_FOUND', '未找到待补缺口。')
119
+ require(catalog()['stages'].index(catalog()['cards'][gap['targetId']]['stage']) <=
120
+ catalog()['stages'].index(state['stage']), 'STAGE_SCOPE', '不能提前解决后续阶段目标。')
121
+ gap['status'] = 'resolved'
122
+ gap.setdefault('resolutions', []).append(dict(copy.deepcopy(resolution), inputId=inp['id']))
123
+ changed.add(gap['targetId'])
124
+ reopen = patch.get('reopen')
125
+ if reopen is not None:
126
+ require(isinstance(reopen, dict) and set(reopen) == {'targetId', 'gapId', 'reason', 'evidence'}
127
+ and intent in {'resume', 'revision'} and text(reopen['reason']),
128
+ 'REOPEN_SCHEMA', '续谈须为 resume/revision,指定 targetId/gapId/reason/evidence。')
129
+ target_id = reopen['targetId']
130
+ require(target_id in state['targets'] and tracked(target_id)
131
+ and catalog()['cards'][target_id]['stage'] == state['stage'], 'REOPEN_SCOPE', '仅恢复当前阶段的指定目标。')
132
+ evidence(state, reopen['evidence'], inp['id'])
133
+ progress = ensure(state, target_id)
134
+ gap = state['interview']['gaps'].get(reopen['gapId'])
135
+ require(gap and gap['targetId'] == target_id and state['targets'][target_id]['status'] in CLOSED,
136
+ 'REOPEN_SCOPE', '仅恢复已经收口的目标及其已记录缺口。')
137
+ progress['reopens'].append(dict(copy.deepcopy(reopen), inputId=inp['id'],
138
+ previousWindowStart=progress['windowStart']))
139
+ progress.update(windowStart=len(state['targets'][target_id]['answerInputIds']),
140
+ noGainStreak=0, lastGain=False, stopReason=None)
141
+ gap.update(windowStart=len(gap['answers']), noGainStreak=0, status='open')
142
+ state['targets'][target_id]['status'] = 'exploring'
143
+ changed.add(target_id)
144
+ return changed
145
+
146
+
147
+ def assess(state, target_id, gap_id, assessment, inp):
148
+ require(isinstance(assessment, dict) and set(assessment) ==
149
+ {'hasNewInformation', 'resolved', 'summary', 'evidence'}
150
+ and type(assessment['hasNewInformation']) is bool and type(assessment['resolved']) is bool
151
+ and text(assessment['summary']), 'ASSESSMENT_REQUIRED',
152
+ '采访回答须提交 interview.assessment:hasNewInformation/resolved/summary/evidence。')
153
+ evidence(state, assessment['evidence'], inp['id'])
154
+ progress = ensure(state, target_id)
155
+ gap = state['interview']['gaps'].get(gap_id or initial_id(target_id))
156
+ require(gap and gap['targetId'] == target_id, 'GAP_NOT_FOUND', '未找到本轮实际展示的待答缺口。')
157
+ gain = assessment['hasNewInformation']
158
+ gap['answers'].append(dict(copy.deepcopy(assessment), inputId=inp['id']))
159
+ gap['noGainStreak'] = 0 if gain else gap['noGainStreak'] + 1
160
+ progress['noGainStreak'] = 0 if gain else progress['noGainStreak'] + 1
161
+ progress['lastGain'] = gain
162
+ if assessment['resolved']:
163
+ gap['status'] = 'resolved'
164
+ elif gap['noGainStreak'] >= NO_GAIN_LIMIT or len(gap['answers']) - gap['windowStart'] >= GAP_LIMIT:
165
+ gap['status'] = 'deferred'
166
+
167
+
168
+ def settle(state):
169
+ for target_id, progress in state.get('interview', {}).get('targets', {}).items():
170
+ target, card = state['targets'][target_id], catalog()['cards'][target_id]
171
+ gaps = [g for g in state['interview']['gaps'].values() if g['targetId'] == target_id]
172
+ unresolved = [g for g in gaps if g['status'] != 'resolved'
173
+ and (g['answers'] or g['id'] != initial_id(target_id))]
174
+ require(target['status'] != 'sufficient' or not unresolved, 'UNRESOLVED_GAP',
175
+ '已记录的缺口仍未解决,不能将目标标为充分;可暂放并保留范围说明。', {'targetId': target_id})
176
+ reason = ('target_limit' if count(state, target_id) >= card['maxAnswers'] else
177
+ 'no_new_information' if progress['noGainStreak'] >= NO_GAIN_LIMIT else
178
+ 'review_without_gain' if count(state, target_id) >= card.get('reviewAfter', card['maxAnswers'])
179
+ and not progress['lastGain'] else None)
180
+ if target['status'] not in CLOSED:
181
+ if reason:
182
+ progress['stopReason'] = reason
183
+ target['status'] = 'exhausted'
184
+ elif any(g['status'] == 'deferred' for g in gaps) and not any(g['status'] == 'open' for g in gaps):
185
+ progress['stopReason'] = 'gap_limit'
186
+ target['status'] = 'exhausted'
187
+ if target['status'] in CLOSED:
188
+ for gap in gaps:
189
+ if gap['status'] == 'open':
190
+ gap['status'] = 'resolved' if target['status'] == 'sufficient' else 'deferred'
191
+ if gap['status'] == 'deferred' and gap['description'] not in target['gaps']:
192
+ target['gaps'].append(gap['description'])
193
+ resolved = {g['description'] for g in gaps if g['status'] == 'resolved'}
194
+ target['gaps'] = [description for description in target['gaps'] if description not in resolved]
195
+
196
+
197
+ def blockers(state, stage, target_id=None):
198
+ return [g for g in state.get('interview', {}).get('gaps', {}).values()
199
+ if g['blocking'] and g['status'] != 'resolved'
200
+ and catalog()['cards'][g['targetId']]['stage'] == stage
201
+ and (target_id is None or g['targetId'] == target_id)]
202
+
203
+
204
+ def guidance(state):
205
+ result = {}
206
+ for target_id, target in state['targets'].items():
207
+ if catalog()['cards'][target_id]['stage'] != state['stage'] or not tracked(target_id):
208
+ continue
209
+ card = catalog()['cards'][target_id]
210
+ result[target_id] = {
211
+ 'answersThisWindow': count(state, target_id), 'maxAnswers': card['maxAnswers'],
212
+ 'reviewDue': count(state, target_id) >= card.get('reviewAfter', card['maxAnswers']),
213
+ 'initialGapId': initial_id(target_id),
214
+ }
215
+ return result