nobrainer-tech-flow 0.0.0-stage → 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.
Files changed (149) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +18 -0
  3. package/.claude-plugin/plugin.json +21 -0
  4. package/.codex-plugin/plugin.json +47 -0
  5. package/.opencode/INSTALL.md +37 -0
  6. package/.opencode/plugins/nobrainer-tech-skills.js +66 -0
  7. package/.pi/extensions/nobrainer-tech-skills.js +64 -0
  8. package/AGENTS.md +204 -0
  9. package/CLAUDE.md +204 -0
  10. package/CONTRIBUTING.md +70 -0
  11. package/GEMINI.md +1 -0
  12. package/LICENSE +21 -0
  13. package/README.md +658 -2
  14. package/SECURITY.md +33 -0
  15. package/adapters/bootstrap.md +27 -0
  16. package/assets/nobrainer-tech-logo.svg +1 -0
  17. package/assets/nobrainer-workflow.svg +155 -0
  18. package/assets/plugin-logo.png +0 -0
  19. package/bin/nobrainer-tech-flow.mjs +48 -0
  20. package/docs/BOUNDED_RUNNER.md +60 -0
  21. package/docs/CHAT_PLUGINS.md +29 -0
  22. package/docs/COMPATIBILITY.md +371 -0
  23. package/docs/INSTALL.md +626 -0
  24. package/docs/MIGRATION_TO_FLOW.md +117 -0
  25. package/docs/NAMING.md +39 -0
  26. package/docs/PRIVACY.md +9 -0
  27. package/docs/SESSION_RESTART.md +70 -0
  28. package/docs/SKILL_CURATION.md +124 -0
  29. package/docs/TESTING.md +232 -0
  30. package/docs/TRY_IT.md +109 -0
  31. package/docs/releases/v1.0.0.md +81 -0
  32. package/docs/releases/v1.1.0.md +85 -0
  33. package/docs/releases/v1.10.0.md +34 -0
  34. package/docs/releases/v1.11.0-publication-readback.md +25 -0
  35. package/docs/releases/v1.11.0.md +42 -0
  36. package/docs/releases/v1.12.0.md +30 -0
  37. package/docs/releases/v1.13.0.md +46 -0
  38. package/docs/releases/v1.14.0.md +43 -0
  39. package/docs/releases/v1.14.1.md +21 -0
  40. package/docs/releases/v1.2.0.md +87 -0
  41. package/docs/releases/v1.2.1.md +92 -0
  42. package/docs/releases/v1.3.0-publication-readback.md +69 -0
  43. package/docs/releases/v1.3.0.md +66 -0
  44. package/docs/releases/v1.3.1-publication-readback.md +76 -0
  45. package/docs/releases/v1.3.1.md +59 -0
  46. package/docs/releases/v1.4.0-publication-readback.md +81 -0
  47. package/docs/releases/v1.4.0.md +43 -0
  48. package/docs/releases/v1.5.0-publication-readback.md +76 -0
  49. package/docs/releases/v1.5.0.md +54 -0
  50. package/docs/releases/v1.6.0.md +64 -0
  51. package/docs/releases/v1.6.1-publication-readback.md +53 -0
  52. package/docs/releases/v1.6.1.md +82 -0
  53. package/docs/releases/v1.7.0.md +42 -0
  54. package/docs/releases/v1.7.1-publication-readback.md +64 -0
  55. package/docs/releases/v1.7.1.md +49 -0
  56. package/docs/releases/v1.8.0.md +63 -0
  57. package/docs/releases/v1.8.1.md +32 -0
  58. package/docs/releases/v1.9.0.md +33 -0
  59. package/docs/releases/v2.0.0-candidate.md +28 -0
  60. package/docs/releases/v2.0.0.md +53 -0
  61. package/docs/releases/v2.0.1.md +135 -0
  62. package/docs/releases/v2.0.2.md +189 -0
  63. package/docs/releases/v2.1.0.md +64 -0
  64. package/docs/releases/v2.1.1.md +43 -0
  65. package/docs/releases/v2.2.0.md +64 -0
  66. package/docs/releases/v2.2.1.md +11 -0
  67. package/docs/releases/v2.3.0.md +97 -0
  68. package/docs/releases/v2.3.1.md +71 -0
  69. package/docs/releases/v2.3.2.md +242 -0
  70. package/docs/releases/v2.4.0.md +126 -0
  71. package/gemini-extension.json +6 -0
  72. package/hooks/claude-hooks.json +17 -0
  73. package/hooks/hooks-cursor.json +10 -0
  74. package/hooks/run-hook.cmd +36 -0
  75. package/hooks/session-start +38 -0
  76. package/package.json +69 -4
  77. package/plugin.json +51 -0
  78. package/scripts/build_plugin.py +50 -0
  79. package/scripts/check_flow_update.py +18 -0
  80. package/scripts/check_public_commit.py +86 -0
  81. package/scripts/fetch_public_refs.py +69 -0
  82. package/scripts/install.py +600 -0
  83. package/scripts/install.sh +1392 -0
  84. package/scripts/install_guards.py +279 -0
  85. package/scripts/install_personalization.py +527 -0
  86. package/scripts/install_skills.py +951 -0
  87. package/scripts/npm_install.py +85 -0
  88. package/scripts/recommend_flow_setup.py +1522 -0
  89. package/scripts/validate_skills.py +465 -0
  90. package/skills/nobrainer-auto-fine-tune/SKILL.md +79 -0
  91. package/skills/nobrainer-auto-fine-tune/references/capacity-audit.md +49 -0
  92. package/skills/nobrainer-autoimprove/SKILL.md +212 -0
  93. package/skills/nobrainer-autoimprove/scripts/count_words.py +22 -0
  94. package/skills/nobrainer-browser/SKILL.md +234 -0
  95. package/skills/nobrainer-browser/references/cdp-profile-restart.md +93 -0
  96. package/skills/nobrainer-build/SKILL.md +173 -0
  97. package/skills/nobrainer-codex-context/SKILL.md +111 -0
  98. package/skills/nobrainer-codex-context/scripts/codex_context.py +175 -0
  99. package/skills/nobrainer-decide/SKILL.md +198 -0
  100. package/skills/nobrainer-dispatcher/SKILL.md +256 -0
  101. package/skills/nobrainer-dispatcher/agents/openai.yaml +4 -0
  102. package/skills/nobrainer-rca/SKILL.md +163 -0
  103. package/skills/nobrainer-research/SKILL.md +131 -0
  104. package/skills/nobrainer-review/SKILL.md +195 -0
  105. package/skills/nobrainer-security/SKILL.md +166 -0
  106. package/skills/nobrainer-sessions/SKILL.md +223 -0
  107. package/skills/nobrainer-sessions/references/evidence-receipt.md +55 -0
  108. package/skills/nobrainer-sessions/references/protocol.md +337 -0
  109. package/skills/nobrainer-sessions/references/session-restart.md +255 -0
  110. package/skills/nobrainer-sessions/scripts/evidence_receipt.py +373 -0
  111. package/skills/nobrainer-sessions/scripts/restart_gate.py +140 -0
  112. package/skills/nobrainer-sessions/scripts/run_bounded.py +390 -0
  113. package/skills/nobrainer-sessions/scripts/session_title.py +43 -0
  114. package/skills/nobrainer-skill-doctor/SKILL.md +149 -0
  115. package/skills/nobrainer-skill-doctor/references/astra.md +19 -0
  116. package/skills/nobrainer-skill-doctor/scripts/audit.py +100 -0
  117. package/skills/nobrainer-skill-doctor/scripts/context_candidates.py +133 -0
  118. package/skills/nobrainer-spec-driven-development/SKILL.md +132 -0
  119. package/skills/nobrainer-spec-driven-development/references/spec-template.md +114 -0
  120. package/skills/nobrainer-team/SKILL.md +165 -0
  121. package/skills/nobrainer-team/references/team-plan.md +64 -0
  122. package/skills/nobrainer-tech-flow/SKILL.md +239 -0
  123. package/skills/nobrainer-tech-flow/VERSION +1 -0
  124. package/skills/nobrainer-tech-flow/references/autopilot.md +72 -0
  125. package/skills/nobrainer-tech-flow/references/codex-routing.md +21 -0
  126. package/skills/nobrainer-tech-flow/references/communication.md +108 -0
  127. package/skills/nobrainer-tech-flow/references/correction-hooks.md +110 -0
  128. package/skills/nobrainer-tech-flow/references/daily-update.md +28 -0
  129. package/skills/nobrainer-tech-flow/references/delivery.md +243 -0
  130. package/skills/nobrainer-tech-flow/references/idea-to-delivery.md +82 -0
  131. package/skills/nobrainer-tech-flow/references/long-run-state.md +209 -0
  132. package/skills/nobrainer-tech-flow/references/model-routing.md +83 -0
  133. package/skills/nobrainer-tech-flow/references/optional-decisions.md +83 -0
  134. package/skills/nobrainer-tech-flow/references/project-start.md +22 -0
  135. package/skills/nobrainer-tech-flow/references/routing.md +125 -0
  136. package/skills/nobrainer-tech-flow/references/setup.md +180 -0
  137. package/skills/nobrainer-tech-flow/references/worktrees.md +138 -0
  138. package/skills/nobrainer-tech-flow/scripts/check_flow_update.py +298 -0
  139. package/skills/nobrainer-tech-flow/scripts/cleanup_worktree.py +327 -0
  140. package/skills/nobrainer-tech-flow/scripts/decision_config.py +107 -0
  141. package/skills/nobrainer-tech-flow/scripts/flow_guard.py +252 -0
  142. package/skills/nobrainer-tech-flow/scripts/typed_decisions.py +210 -0
  143. package/skills/nobrainer-wiki/SKILL.md +164 -0
  144. package/skills/nobrainer-wiki/references/setup.md +88 -0
  145. package/skills/nobrainer-writing/SKILL.md +272 -0
  146. package/skills/nobrainer-writing/agents/openai.yaml +4 -0
  147. package/skills/nobrainer-writing/references/brief-artifacts.md +357 -0
  148. package/skills/nobrainer-writing/references/plain-technical-english.md +76 -0
  149. package/skills/nobrainer-writing/references/research.md +86 -0
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env python3
2
+ """Local, unsigned execution evidence for an explicit input scope.
3
+
4
+ Capture reuses run_bounded; check never executes the recorded command. Neither
5
+ mode decides test coverage, task acceptance or human approval.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import errno
11
+ import base64
12
+ import hashlib
13
+ import html
14
+ import importlib.util
15
+ import json
16
+ import os
17
+ from pathlib import Path, PurePosixPath
18
+ import stat
19
+ import secrets
20
+ import sys
21
+
22
+ MAX_ENTRIES = 1000
23
+ MAX_INPUT_BYTES = 64 * 1024 * 1024
24
+ MAX_RECEIPT_BYTES = 4 * 1024 * 1024
25
+ MAX_OUTPUT_BYTES = 256 * 1024
26
+
27
+
28
+ class EvidenceError(ValueError):
29
+ pass
30
+
31
+
32
+ def digest(data: bytes) -> str:
33
+ return hashlib.sha256(data).hexdigest()
34
+
35
+
36
+ def relative_path(value: str) -> str:
37
+ if not isinstance(value, str) or not value or len(value.encode('utf-8')) > 512:
38
+ raise EvidenceError('invalid relative input path')
39
+ if '\\' in value or any(ord(c) < 32 for c in value):
40
+ raise EvidenceError('unsafe input path')
41
+ path = PurePosixPath(value)
42
+ if path.is_absolute() or '..' in path.parts or str(path) != value or len(path.parts) > 32:
43
+ raise EvidenceError('input paths must be normalized and relative to root')
44
+ return value
45
+
46
+
47
+ def scope_paths(values: object) -> list[str]:
48
+ if not isinstance(values, list) or not 1 <= len(values) <= 64:
49
+ raise EvidenceError('input scope must contain 1..64 relative paths')
50
+ paths = [relative_path(p) for p in values]
51
+ if len(set(paths)) != len(paths):
52
+ raise EvidenceError('duplicate input scope')
53
+ return sorted(paths)
54
+
55
+
56
+ def require_posix() -> None:
57
+ if os.name != 'posix' or not hasattr(os, 'O_NOFOLLOW'):
58
+ raise EvidenceError('unsupported platform: POSIX no-follow file access is required')
59
+
60
+
61
+ def snapshot(root: Path, scope: list[str]) -> dict[str, dict[str, object]]:
62
+ """Walk selected entries through directory descriptors, never through links."""
63
+ require_posix()
64
+ entries: dict[str, dict[str, object]] = {}
65
+ total = 0
66
+ file_count = 0
67
+ directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
68
+ root_fd = os.open(root, directory_flags)
69
+
70
+ def visit(parent_fd: int, name: str, relative: str, depth: int) -> None:
71
+ nonlocal total, file_count
72
+ if relative in entries:
73
+ return
74
+ relative_path(relative)
75
+ if depth > 32 or len(entries) >= MAX_ENTRIES:
76
+ raise EvidenceError('input entry/depth limit exceeded')
77
+ info = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
78
+ if stat.S_ISDIR(info.st_mode):
79
+ fd = os.open(name, directory_flags, dir_fd=parent_fd)
80
+ try:
81
+ entries[relative] = {'type': 'directory', 'mode': stat.S_IMODE(os.fstat(fd).st_mode)}
82
+ with os.scandir(fd) as iterator:
83
+ names = []
84
+ for entry in iterator:
85
+ if len(names) + len(entries) >= MAX_ENTRIES:
86
+ raise EvidenceError('input entry limit exceeded')
87
+ names.append(entry.name)
88
+ names.sort()
89
+ for child in names:
90
+ child_relative = child if relative == '.' else relative + '/' + child
91
+ visit(fd, child, child_relative, depth + 1)
92
+ finally:
93
+ os.close(fd)
94
+ elif stat.S_ISREG(info.st_mode):
95
+ fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent_fd)
96
+ try:
97
+ before = os.fstat(fd)
98
+ if not stat.S_ISREG(before.st_mode):
99
+ raise EvidenceError('input is not a regular file')
100
+ checksum = hashlib.sha256(); size = 0
101
+ while True:
102
+ chunk = os.read(fd, 65536)
103
+ if not chunk:
104
+ break
105
+ size += len(chunk); total += len(chunk)
106
+ if total > MAX_INPUT_BYTES:
107
+ raise EvidenceError('input byte limit exceeded')
108
+ checksum.update(chunk)
109
+ after = os.fstat(fd)
110
+ identity = lambda item: (item.st_dev, item.st_ino, item.st_size, item.st_mtime_ns, item.st_ctime_ns)
111
+ if identity(before) != identity(after) or size != after.st_size:
112
+ raise EvidenceError('input changed while being fingerprinted')
113
+ entries[relative] = {'type': 'file', 'mode': stat.S_IMODE(after.st_mode), 'bytes': size, 'sha256': checksum.hexdigest()}
114
+ file_count += 1
115
+ finally:
116
+ os.close(fd)
117
+ else:
118
+ raise EvidenceError('symlinks and special input files are unsupported')
119
+
120
+ try:
121
+ for relative in scope:
122
+ parts = PurePosixPath(relative).parts
123
+ parent_fd = os.dup(root_fd)
124
+ try:
125
+ for part in parts[:-1]:
126
+ child_fd = os.open(part, directory_flags, dir_fd=parent_fd)
127
+ os.close(parent_fd); parent_fd = child_fd
128
+ visit(parent_fd, parts[-1] if parts else '.', relative, len(parts))
129
+ finally:
130
+ os.close(parent_fd)
131
+ finally:
132
+ os.close(root_fd)
133
+ if not file_count:
134
+ raise EvidenceError('input scope contains no regular files')
135
+ return dict(sorted(entries.items()))
136
+
137
+
138
+ def load_runner():
139
+ path = Path(__file__).with_name('run_bounded.py')
140
+ spec = importlib.util.spec_from_file_location('flow_evidence_runner', path)
141
+ if spec is None or spec.loader is None:
142
+ raise EvidenceError('bundled bounded runner is unavailable')
143
+ module = importlib.util.module_from_spec(spec)
144
+ spec.loader.exec_module(module)
145
+ return module
146
+
147
+
148
+ def unique_keys(pairs):
149
+ result = {}
150
+ for key, value in pairs:
151
+ if key in result:
152
+ raise EvidenceError('duplicate JSON object key')
153
+ result[key] = value
154
+ return result
155
+
156
+
157
+ def read_receipt(path: Path) -> dict:
158
+ if path.is_symlink() or not path.is_file():
159
+ raise EvidenceError('receipt must be a regular file, not a symlink')
160
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
161
+ try:
162
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
163
+ raise EvidenceError('receipt must be a regular file')
164
+ with os.fdopen(fd, 'rb', closefd=False) as handle:
165
+ raw = handle.read(MAX_RECEIPT_BYTES + 1)
166
+ finally:
167
+ os.close(fd)
168
+ if len(raw) > MAX_RECEIPT_BYTES:
169
+ raise EvidenceError('receipt byte limit exceeded')
170
+ try:
171
+ value = json.loads(raw, object_pairs_hook=unique_keys)
172
+ except (ValueError, RecursionError) as exc:
173
+ raise EvidenceError('invalid receipt JSON') from exc
174
+ if not isinstance(value, dict) or type(value.get('schema_version')) is not int or value.get('schema_version') != 1 or value.get('kind') != 'flow-command-evidence':
175
+ raise EvidenceError('unsupported receipt shape')
176
+ return value
177
+
178
+
179
+ def validate_snapshot(value: object) -> None:
180
+ if not isinstance(value, dict) or not 1 <= len(value) <= MAX_ENTRIES:
181
+ raise EvidenceError('invalid snapshot')
182
+ for name, record in value.items():
183
+ relative_path(name)
184
+ if not isinstance(record, dict) or record.get('type') not in ('file', 'directory'):
185
+ raise EvidenceError('invalid snapshot entry')
186
+ if type(record.get('mode')) is not int or not 0 <= record['mode'] <= 0o7777:
187
+ raise EvidenceError('invalid mode')
188
+ if record['type'] == 'file':
189
+ if type(record.get('bytes')) is not int or not 0 <= record['bytes'] <= MAX_INPUT_BYTES:
190
+ raise EvidenceError('invalid file size')
191
+ if not isinstance(record.get('sha256'), str) or len(record['sha256']) != 64 or any(c not in '0123456789abcdef' for c in record['sha256']):
192
+ raise EvidenceError('invalid fingerprint')
193
+
194
+
195
+ def assess(value: dict, root: Path) -> dict:
196
+ scope = scope_paths(value.get('input_scope'))
197
+ validate_snapshot(value.get('before'))
198
+ if 'after' not in value:
199
+ raise EvidenceError('missing after snapshot')
200
+ if value.get('after') is not None:
201
+ validate_snapshot(value['after'])
202
+ run = value.get('execution')
203
+ if not isinstance(run, dict) or run.get('status') not in ('completed', 'failed', 'timed_out', 'output_limit'):
204
+ raise EvidenceError('invalid execution record')
205
+ if run.get('exit_code') is not None and type(run['exit_code']) is not int:
206
+ raise EvidenceError('invalid exit code')
207
+ for stream in ('stdout', 'stderr'):
208
+ if type(run.get(stream + '_bytes_captured')) is not int or type(run.get(stream + '_truncated')) is not bool:
209
+ raise EvidenceError('invalid captured-output metadata')
210
+ try:
211
+ output = value['output'][stream]
212
+ encoded = output['base64']
213
+ if not isinstance(encoded, str) or len(encoded) > 4 * MAX_OUTPUT_BYTES:
214
+ raise EvidenceError('invalid output size')
215
+ raw = base64.b64decode(encoded, validate=True)
216
+ if len(raw) > MAX_OUTPUT_BYTES or digest(raw) != output['sha256'] or len(raw) != run[stream + '_bytes_captured']:
217
+ raise EvidenceError('output integrity mismatch')
218
+ except (KeyError, TypeError, ValueError) as exc:
219
+ raise EvidenceError('invalid or inconsistent captured output') from exc
220
+ label = value.get('label')
221
+ if not isinstance(label, str) or not 1 <= len(label) <= 120 or any(ord(c) < 32 for c in label):
222
+ raise EvidenceError('invalid check label')
223
+ if run['status'] == 'completed' and (run.get('exit_code') != 0 or run.get('timed_out') is not False or run.get('output_limit_reached') is not False or run.get('signal') is not None or run.get('stdout_truncated') is not False or run.get('stderr_truncated') is not False):
224
+ raise EvidenceError('completed command metadata is inconsistent')
225
+ try:
226
+ current = snapshot(root, scope)
227
+ inputs = 'CURRENT' if current == value['after'] else 'STALE'
228
+ except (OSError, EvidenceError):
229
+ inputs = 'UNAVAILABLE'
230
+ if value['after'] is None:
231
+ inputs = 'UNAVAILABLE'
232
+ elif value['before'] != value['after']:
233
+ inputs = 'CHANGED_DURING_RUN'
234
+ return {
235
+ 'label': label,
236
+ 'inputs': inputs,
237
+ 'input_scope': scope,
238
+ 'execution': run['status'],
239
+ 'exit_code': run.get('exit_code'),
240
+ 'output_integrity': 'CONSISTENT_NOT_AUTHENTICATED',
241
+ 'test_counts': 'UNKNOWN',
242
+ 'coverage': 'UNKNOWN',
243
+ 'task_acceptance': 'NOT_EVALUATED',
244
+ 'human_approval': 'NOT_RECORDED',
245
+ 'authenticity': 'NOT_VERIFIED',
246
+ }
247
+
248
+
249
+ def render_card(report: dict) -> str:
250
+ def safe(value):
251
+ return html.escape(str(value)).replace('|', '&#124;').replace('`', '&#96;')
252
+ rows = [('Check', report['label']), ('Input freshness', report['inputs']),
253
+ ('Command', report['execution']), ('Exit code', report['exit_code']),
254
+ ('Test counts / coverage', 'UNKNOWN'), ('Task acceptance', 'NOT_EVALUATED'),
255
+ ('Human approval', 'NOT_RECORDED'), ('Authenticity', 'NOT_VERIFIED')]
256
+ return '# Execution evidence\n\n| Field | Observed scope |\n|---|---|\n' + ''.join(
257
+ f'| {label} | {safe(value)} |\n' for label, value in rows
258
+ ) + '\nCheck the logs, actual criteria and selected input scope before accepting the task.\n'
259
+
260
+
261
+ def write_receipt(parent_fd: int, name: str, encoded: bytes) -> None:
262
+ """Atomically create evidence in the pinned directory without replacement."""
263
+ temporary = '.flow-evidence-' + secrets.token_hex(16)
264
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
265
+ 0o600, dir_fd=parent_fd)
266
+ try:
267
+ with os.fdopen(fd, 'wb') as handle:
268
+ handle.write(encoded)
269
+ handle.flush()
270
+ os.fsync(handle.fileno())
271
+ os.link(temporary, name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd,
272
+ follow_symlinks=False)
273
+ try:
274
+ os.fsync(parent_fd)
275
+ except OSError as exc:
276
+ if exc.errno not in {errno.EINVAL, errno.ENOTSUP, errno.EPERM}:
277
+ raise
278
+ finally:
279
+ os.unlink(temporary, dir_fd=parent_fd)
280
+
281
+
282
+ def capture(args) -> int:
283
+ require_posix()
284
+ runner = load_runner()
285
+ root = args.root.expanduser().resolve(strict=True)
286
+ scope = scope_paths(args.input)
287
+ receipt = args.receipt.expanduser().absolute()
288
+ receipt = receipt.parent.resolve(strict=True) / receipt.name
289
+ if receipt.exists() or receipt.is_symlink():
290
+ raise EvidenceError('receipt already exists; not overwritten')
291
+ if not args.label or len(args.label) > 120 or any(ord(c) < 32 for c in args.label):
292
+ raise EvidenceError('label must be 1..120 printable characters')
293
+ if args.max_output_bytes > MAX_OUTPUT_BYTES:
294
+ raise EvidenceError('maximum captured output is 262144 bytes')
295
+ for relative in scope:
296
+ selected = root / relative
297
+ if receipt == selected or receipt.is_relative_to(selected):
298
+ raise EvidenceError('receipt must be outside every selected input path')
299
+ parent_fd = os.open(receipt.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
300
+ try:
301
+ parent_info = os.fstat(parent_fd)
302
+ before = snapshot(root, scope)
303
+ command = args.command[1:] if args.command and args.command[0] == '--' else args.command
304
+ if not command:
305
+ raise EvidenceError('an explicit argv command is required after --')
306
+ run_args = argparse.Namespace(command=command, cwd=root, wall_seconds=args.wall_seconds,
307
+ max_output_bytes=args.max_output_bytes, record_argv=args.record_argv)
308
+ execution, code, captured = runner.run_bounded(run_args)
309
+ # The runner calls a run completed when the command exits 0, even if it had to cut
310
+ # its output short, for example when a detached child keeps the pipe open. Such a
311
+ # run did not deliver all of its output, so it is recorded as an output-limit run.
312
+ if execution.get('status') == 'completed' and (execution.get('stdout_truncated') or execution.get('stderr_truncated')):
313
+ execution = {**execution, 'status': 'output_limit'}
314
+ after = None; snapshot_error = None
315
+ try:
316
+ after = snapshot(root, scope)
317
+ except (OSError, EvidenceError) as exc:
318
+ snapshot_error = type(exc).__name__
319
+ value = {'schema_version': 1, 'kind': 'flow-command-evidence', 'label': args.label,
320
+ 'input_scope': scope, 'before': before, 'after': after, 'snapshot_error': snapshot_error,
321
+ 'execution': execution,
322
+ 'output': {key: {'base64': base64.b64encode(raw).decode('ascii'), 'sha256': digest(raw)} for key, raw in captured.items()}}
323
+ encoded = (json.dumps(value, sort_keys=True, indent=2) + '\n').encode('utf-8')
324
+ if len(encoded) > MAX_RECEIPT_BYTES:
325
+ raise EvidenceError('receipt byte limit exceeded; command ran but receipt could not be recorded')
326
+ try:
327
+ current_parent = os.stat(receipt.parent, follow_symlinks=False)
328
+ except OSError as exc:
329
+ raise EvidenceError('receipt directory unavailable; command ran but evidence was not written') from exc
330
+ if not stat.S_ISDIR(current_parent.st_mode) or (current_parent.st_dev, current_parent.st_ino) != (parent_info.st_dev, parent_info.st_ino):
331
+ raise EvidenceError('receipt directory changed; command ran but evidence was not written')
332
+ # Checked before writing, so a receipt that a later check would reject is never left.
333
+ report = assess(value, root)
334
+ write_receipt(parent_fd, receipt.name, encoded)
335
+ print(json.dumps(report, sort_keys=True))
336
+ # A zero exit is success only for evidence that check would also accept.
337
+ return code if code else (0 if report['inputs'] == 'CURRENT' and report['execution'] == 'completed' else 1)
338
+ finally:
339
+ os.close(parent_fd)
340
+
341
+
342
+ def main(argv=None) -> int:
343
+ parser = argparse.ArgumentParser(description=__doc__)
344
+ sub = parser.add_subparsers(dest='mode', required=True)
345
+ run = sub.add_parser('capture', help='run an already-authorized command and record local evidence')
346
+ run.add_argument('--root', required=True, type=Path)
347
+ run.add_argument('--input', action='append', required=True)
348
+ run.add_argument('--receipt', required=True, type=Path)
349
+ run.add_argument('--label', required=True)
350
+ run.add_argument('--wall-seconds', required=True, type=lambda value: load_runner().positive_float(value))
351
+ run.add_argument('--max-output-bytes', required=True, type=lambda value: load_runner().positive_int(value))
352
+ run.add_argument('--record-argv', action='store_true')
353
+ run.add_argument('command', nargs=argparse.REMAINDER)
354
+ check = sub.add_parser('check', help='inspect an existing receipt; never execute its command')
355
+ check.add_argument('--root', required=True, type=Path)
356
+ check.add_argument('--receipt', required=True, type=Path)
357
+ check.add_argument('--format', choices=['json', 'markdown'], default='json')
358
+ args = parser.parse_args(argv)
359
+ try:
360
+ if args.mode == 'capture':
361
+ return capture(args)
362
+ require_posix()
363
+ root = args.root.expanduser().resolve(strict=True)
364
+ report = assess(read_receipt(args.receipt), root)
365
+ print(render_card(report) if args.format == 'markdown' else json.dumps(report, sort_keys=True))
366
+ return 0 if report['inputs'] == 'CURRENT' and report['execution'] == 'completed' else 1
367
+ except (OSError, ValueError, RuntimeError) as exc:
368
+ print(f'evidence_receipt: {exc}', file=sys.stderr)
369
+ return 2
370
+
371
+
372
+ if __name__ == '__main__':
373
+ raise SystemExit(main())
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """Evaluate normalized session observations; never create, send, archive or lock.
3
+
4
+ Optional hook adapters supply verified observations. Missing safety observations
5
+ fail closed. This helper is not a session backend or proof of host capability.
6
+ """
7
+ from __future__ import annotations
8
+ import json
9
+ import sys
10
+
11
+
12
+ def decide(state: dict) -> dict[str, object]:
13
+ assessment = {}
14
+ def result(action: str, reason: str) -> dict[str, object]:
15
+ return {"action": action, "reason": reason, **({"assessment": assessment} if assessment else {})}
16
+
17
+ def yes(name: str) -> bool:
18
+ return state.get(name) is True
19
+
20
+ def number(name: str) -> float | None:
21
+ value = state.get(name)
22
+ return value if type(value) in (int, float) and 0 <= value < float("inf") else None
23
+
24
+ if type(state.get("schema")) is not int or state["schema"] != 1:
25
+ return result("BLOCKED", "unsupported_schema")
26
+ if not isinstance(state.get("source_id"), str) or not state["source_id"].strip():
27
+ return result("BLOCKED", "source_identity")
28
+ if not isinstance(state.get("goal_id"), str) or not state["goal_id"].strip():
29
+ return result("BLOCKED", "goal_identity")
30
+ target = state.get("target_id")
31
+ if yes("ownership_committed") and (
32
+ not isinstance(target, str) or not target.strip() or target == state["source_id"]
33
+ ):
34
+ return result("BLOCKED", "committed_target_identity")
35
+ # After a committed transfer the source must never resume implementation.
36
+ if yes("ownership_committed"):
37
+ if not yes("target_takeover_readback") or not yes("ack_matches"):
38
+ return result("VERIFY_TAKEOVER", "source_remains_readonly")
39
+ if not yes("source_retired_readback"):
40
+ return result("VERIFY_SOURCE_RELEASE", "do_not_archive_an_unreleased_source")
41
+ if state.get("archive_status") == "unknown":
42
+ return result("RECONCILE_ARCHIVE", "target_owns_work_never_recreate")
43
+ if yes("source_archived_readback"):
44
+ return result("COMPLETE", "takeover_and_archive_verified")
45
+ if not yes("archive_authorized") or not yes("archive_supported"):
46
+ return result("ARCHIVE_PENDING", "target_owns_work_source_remains_readonly")
47
+ return result("ARCHIVE_SOURCE", "archive_exact_source_only")
48
+ creation_status = state.get("creation_status")
49
+ if creation_status == "unknown":
50
+ return result("RECONCILE_TARGET", "never_retry_uncertain_create")
51
+ if creation_status not in ("not_sent", "confirmed"):
52
+ return result("BLOCKED", "creation_status")
53
+ target = state.get("target_id")
54
+ if creation_status == "confirmed":
55
+ if not isinstance(target, str) or not target.strip() or target == state["source_id"]:
56
+ return result("BLOCKED", "target_identity")
57
+ elif target:
58
+ return result("RECONCILE_TARGET", "unexpected_target")
59
+ if not yes("remaining_work"):
60
+ return result("FINISH" if state.get("remaining_work") is False else "BLOCKED", "remaining_work")
61
+ if yes("required_budget_unmeasurable"):
62
+ return result("BLOCKED", "required_budget_unmeasurable")
63
+ if state.get("mode") not in ("off", "adaptive", "daily"):
64
+ return result("BLOCKED", "invalid_mode")
65
+ if state["mode"] == "off" or not yes("policy_authorized"):
66
+ return result("CHECKPOINT_ONLY", "restart_not_authorized")
67
+ # A short forecast limits optimistic claims about days of future work.
68
+ current, fresh, overhead, calls = (number(name) for name in (
69
+ "current_input_tokens", "fresh_input_tokens", "restart_overhead_tokens", "remaining_calls"
70
+ ))
71
+ measured = all(value is not None for value in (current, fresh, overhead, calls))
72
+ pays_back = False
73
+ if measured:
74
+ horizon = min(calls, 3)
75
+ avoided = (current - fresh) * horizon
76
+ pays_back = horizon >= 1 and avoided > 2 * overhead
77
+ assessment = {
78
+ "basis": "estimated_token_proxy_not_billing",
79
+ "horizon_calls": horizon,
80
+ "estimated_net_tokens_saved": avoided - overhead,
81
+ "pays_back_with_margin": pays_back,
82
+ }
83
+ # Pressure and a materially smaller startup justify health rotation even
84
+ # without a price/overhead forecast. Capacity is host-observed, never guessed.
85
+ capacity = number("context_capacity_tokens")
86
+ occupancy = current / capacity if current is not None and capacity else None
87
+ pressure = yes("context_pressure") or (occupancy is not None and occupancy >= 0.70)
88
+ smaller_start = current is not None and current > 0 and fresh is not None and fresh <= 0.80 * current
89
+ health_benefit = pressure and smaller_start
90
+ assessment.update({
91
+ "context_occupancy": occupancy,
92
+ "context_pressure": pressure,
93
+ "materially_smaller_start": smaller_start,
94
+ "health_rotation_qualified": health_benefit,
95
+ })
96
+ triggered = yes("explicit_restart") or pressure or pays_back
97
+ compactions = number("compactions")
98
+ triggered = triggered or (compactions is not None and compactions >= 2)
99
+ age = number("session_age_seconds")
100
+ triggered = triggered or (state["mode"] == "daily" and age is not None and age >= 86400)
101
+ if creation_status == "not_sent" and not triggered:
102
+ return result("CONTINUE", "no_observed_trigger")
103
+ if not yes("safe_boundary") or number("active_writers") != 0 or number("pending_operations") != 0:
104
+ return result("WAIT_SAFE_BOUNDARY", "writes_or_operations_not_released")
105
+ if not yes("checkpoint_readback") or not yes("checkpoint_current"):
106
+ return result("CHECKPOINT", "persist_and_verify_current_state")
107
+ if creation_status == "not_sent":
108
+ if not yes("explicit_restart") and not pays_back and not health_benefit:
109
+ return result("CHECKPOINT_ONLY" if not measured else "CONTINUE", "benefit_unmeasured" if not measured else "restart_does_not_pay_back")
110
+ if not yes("explicit_restart") and not yes("progress_since_restart"):
111
+ return result("CONTINUE", "prevent_restart_without_progress")
112
+ if not yes("transfer_supported"):
113
+ return result("MANUAL_HANDOFF", "no_authoritative_ownership_transfer")
114
+ if not yes("fresh_create_supported") or not yes("target_read_supported"):
115
+ return result("MANUAL_HANDOFF", "native_transport_unavailable")
116
+ return result("CREATE_FRESH", "record_identity_and_receipt_before_retry")
117
+ if not yes("ack_matches"):
118
+ return result("VERIFY_TARGET", "exact_goal_checkpoint_and_target_ack_required")
119
+ if not yes("transfer_supported"):
120
+ return result("MANUAL_HANDOFF", "no_authoritative_ownership_transfer")
121
+ return result("COMMIT_TRANSFER", "conditional_transfer_required_target_stays_readonly")
122
+
123
+
124
+ def main() -> int:
125
+ try:
126
+ raw = sys.stdin.buffer.read(65537)
127
+ if len(raw) > 65536:
128
+ raise ValueError("input_too_large")
129
+ state = json.loads(raw)
130
+ if not isinstance(state, dict):
131
+ raise ValueError("expected_object")
132
+ output = decide(state)
133
+ except (ValueError, UnicodeDecodeError):
134
+ output = {"action": "BLOCKED", "reason": "invalid_input"}
135
+ print(json.dumps(output, sort_keys=True))
136
+ return 2 if output["action"] == "BLOCKED" else 0
137
+
138
+
139
+ if __name__ == "__main__":
140
+ raise SystemExit(main())