taphound 0.2.0-dev.1

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 (176) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +200 -0
  3. package/README.zh-CN.md +200 -0
  4. package/assets/brand/taphound-mark.svg +9 -0
  5. package/assets/skills/taphound-ai-journey/GUIDE.md +862 -0
  6. package/assets/skills/taphound-ai-journey/SKILL.md +377 -0
  7. package/assets/skills/taphound-ai-journey/prompts/analyze-project.md +526 -0
  8. package/assets/skills/taphound-ai-journey/prompts/check-completion.md +52 -0
  9. package/assets/skills/taphound-ai-journey/prompts/generate-step.md +109 -0
  10. package/assets/skills/taphound-ai-journey/schemas/observe-output.json +42 -0
  11. package/assets/skills/taphound-ai-journey/schemas/project-context.json +81 -0
  12. package/assets/skills/taphound-ai-journey/schemas/proposed-step-envelope.json +267 -0
  13. package/assets/skills/taphound-ai-journey/templates/project-context.example.json +49 -0
  14. package/dist/adapters/adb/adb-adapter.d.ts +23 -0
  15. package/dist/adapters/adb/adb-adapter.js +218 -0
  16. package/dist/adapters/android-cli/android-cli-adapter.d.ts +11 -0
  17. package/dist/adapters/android-cli/android-cli-adapter.js +64 -0
  18. package/dist/adapters/android-cli/layout-parser.d.ts +3 -0
  19. package/dist/adapters/android-cli/layout-parser.js +174 -0
  20. package/dist/adapters/clock/system-clock.d.ts +5 -0
  21. package/dist/adapters/clock/system-clock.js +13 -0
  22. package/dist/adapters/filesystem/artifact-store.d.ts +4 -0
  23. package/dist/adapters/filesystem/artifact-store.js +73 -0
  24. package/dist/adapters/filesystem/generation-meta-writer.d.ts +16 -0
  25. package/dist/adapters/filesystem/generation-meta-writer.js +34 -0
  26. package/dist/adapters/filesystem/generation-session-store.d.ts +68 -0
  27. package/dist/adapters/filesystem/generation-session-store.js +1603 -0
  28. package/dist/adapters/filesystem/journey-writer.d.ts +16 -0
  29. package/dist/adapters/filesystem/journey-writer.js +34 -0
  30. package/dist/adapters/filesystem/project-bound-file.d.ts +6 -0
  31. package/dist/adapters/filesystem/project-bound-file.js +240 -0
  32. package/dist/adapters/filesystem/project-file-inspector.d.ts +8 -0
  33. package/dist/adapters/filesystem/project-file-inspector.js +198 -0
  34. package/dist/adapters/filesystem/skill-installer.d.ts +10 -0
  35. package/dist/adapters/filesystem/skill-installer.js +39 -0
  36. package/dist/adapters/process/node-process-runner.d.ts +8 -0
  37. package/dist/adapters/process/node-process-runner.js +155 -0
  38. package/dist/adapters/prompt/inquirer-generation-prompt.d.ts +55 -0
  39. package/dist/adapters/prompt/inquirer-generation-prompt.js +213 -0
  40. package/dist/adapters/prompt/inquirer-init-prompt.d.ts +32 -0
  41. package/dist/adapters/prompt/inquirer-init-prompt.js +55 -0
  42. package/dist/adapters/prompt/inquirer-recorder-prompt.d.ts +47 -0
  43. package/dist/adapters/prompt/inquirer-recorder-prompt.js +157 -0
  44. package/dist/application/assertion/expectation-evaluator.d.ts +61 -0
  45. package/dist/application/assertion/expectation-evaluator.js +207 -0
  46. package/dist/application/collector/logcat-collector.d.ts +39 -0
  47. package/dist/application/collector/logcat-collector.js +107 -0
  48. package/dist/application/context/context-validator.d.ts +27 -0
  49. package/dist/application/context/context-validator.js +120 -0
  50. package/dist/application/doctor/doctor-service.d.ts +35 -0
  51. package/dist/application/doctor/doctor-service.js +178 -0
  52. package/dist/application/generation/focused-input.d.ts +2 -0
  53. package/dist/application/generation/focused-input.js +9 -0
  54. package/dist/application/generation/generation-confirmation-service.d.ts +76 -0
  55. package/dist/application/generation/generation-confirmation-service.js +383 -0
  56. package/dist/application/generation/generation-finalizer.d.ts +67 -0
  57. package/dist/application/generation/generation-finalizer.js +646 -0
  58. package/dist/application/generation/generation-publisher.d.ts +46 -0
  59. package/dist/application/generation/generation-publisher.js +178 -0
  60. package/dist/application/generation/generation-starter.d.ts +31 -0
  61. package/dist/application/generation/generation-starter.js +102 -0
  62. package/dist/application/generation/generation-step-executor.d.ts +52 -0
  63. package/dist/application/generation/generation-step-executor.js +668 -0
  64. package/dist/application/generation/proposed-step-validator.d.ts +11 -0
  65. package/dist/application/generation/proposed-step-validator.js +88 -0
  66. package/dist/application/generation/risk-evaluator.d.ts +9 -0
  67. package/dist/application/generation/risk-evaluator.js +14 -0
  68. package/dist/application/generation/runtime-observer.d.ts +39 -0
  69. package/dist/application/generation/runtime-observer.js +157 -0
  70. package/dist/application/init/init-service.d.ts +17 -0
  71. package/dist/application/init/init-service.js +44 -0
  72. package/dist/application/interaction/action-executor.d.ts +25 -0
  73. package/dist/application/interaction/action-executor.js +103 -0
  74. package/dist/application/interaction/fallback-resolver.d.ts +24 -0
  75. package/dist/application/interaction/fallback-resolver.js +48 -0
  76. package/dist/application/interaction/scroll-to-executor.d.ts +40 -0
  77. package/dist/application/interaction/scroll-to-executor.js +191 -0
  78. package/dist/application/locator/locator-resolver.d.ts +20 -0
  79. package/dist/application/locator/locator-resolver.js +89 -0
  80. package/dist/application/project/project-describer.d.ts +14 -0
  81. package/dist/application/project/project-describer.js +8 -0
  82. package/dist/application/recorder/locator-selector.d.ts +10 -0
  83. package/dist/application/recorder/locator-selector.js +80 -0
  84. package/dist/application/recorder/recorder-service.d.ts +41 -0
  85. package/dist/application/recorder/recorder-service.js +331 -0
  86. package/dist/application/report/report-writer.d.ts +10 -0
  87. package/dist/application/report/report-writer.js +39 -0
  88. package/dist/application/runtime/activity-waiter.d.ts +33 -0
  89. package/dist/application/runtime/activity-waiter.js +73 -0
  90. package/dist/application/runtime/launch-failure.d.ts +2 -0
  91. package/dist/application/runtime/launch-failure.js +20 -0
  92. package/dist/application/runtime/process-waiter.d.ts +27 -0
  93. package/dist/application/runtime/process-waiter.js +73 -0
  94. package/dist/application/runtime/step-runner.d.ts +50 -0
  95. package/dist/application/runtime/step-runner.js +429 -0
  96. package/dist/application/runtime/verify-runtime.d.ts +44 -0
  97. package/dist/application/runtime/verify-runtime.js +350 -0
  98. package/dist/application/wait/idle-waiter.d.ts +30 -0
  99. package/dist/application/wait/idle-waiter.js +90 -0
  100. package/dist/cli/commands/context.d.ts +3 -0
  101. package/dist/cli/commands/context.js +94 -0
  102. package/dist/cli/commands/doctor.d.ts +3 -0
  103. package/dist/cli/commands/doctor.js +51 -0
  104. package/dist/cli/commands/generation.d.ts +3 -0
  105. package/dist/cli/commands/generation.js +518 -0
  106. package/dist/cli/commands/init.d.ts +3 -0
  107. package/dist/cli/commands/init.js +104 -0
  108. package/dist/cli/commands/project.d.ts +3 -0
  109. package/dist/cli/commands/project.js +58 -0
  110. package/dist/cli/commands/record.d.ts +3 -0
  111. package/dist/cli/commands/record.js +88 -0
  112. package/dist/cli/commands/verify.d.ts +3 -0
  113. package/dist/cli/commands/verify.js +106 -0
  114. package/dist/cli/dependencies.d.ts +66 -0
  115. package/dist/cli/dependencies.js +204 -0
  116. package/dist/cli/main.d.ts +8 -0
  117. package/dist/cli/main.js +73 -0
  118. package/dist/cli/output.d.ts +16 -0
  119. package/dist/cli/output.js +22 -0
  120. package/dist/cli/program.d.ts +3 -0
  121. package/dist/cli/program.js +43 -0
  122. package/dist/domain/activity.d.ts +7 -0
  123. package/dist/domain/activity.js +65 -0
  124. package/dist/domain/app-process.d.ts +7 -0
  125. package/dist/domain/app-process.js +10 -0
  126. package/dist/domain/config.d.ts +15 -0
  127. package/dist/domain/config.js +17 -0
  128. package/dist/domain/failure.d.ts +4 -0
  129. package/dist/domain/failure.js +43 -0
  130. package/dist/domain/generation.d.ts +912 -0
  131. package/dist/domain/generation.js +342 -0
  132. package/dist/domain/init.d.ts +42 -0
  133. package/dist/domain/init.js +114 -0
  134. package/dist/domain/journey.d.ts +673 -0
  135. package/dist/domain/journey.js +109 -0
  136. package/dist/domain/layout.d.ts +35 -0
  137. package/dist/domain/layout.js +33 -0
  138. package/dist/domain/locator.d.ts +8 -0
  139. package/dist/domain/locator.js +5 -0
  140. package/dist/domain/project-context.d.ts +101 -0
  141. package/dist/domain/project-context.js +64 -0
  142. package/dist/domain/proposed-step.d.ts +341 -0
  143. package/dist/domain/proposed-step.js +87 -0
  144. package/dist/domain/report.d.ts +349 -0
  145. package/dist/domain/report.js +135 -0
  146. package/dist/domain/runtime-snapshot.d.ts +16 -0
  147. package/dist/domain/runtime-snapshot.js +46 -0
  148. package/dist/ports/adb.d.ts +42 -0
  149. package/dist/ports/adb.js +1 -0
  150. package/dist/ports/android-cli.d.ts +21 -0
  151. package/dist/ports/android-cli.js +1 -0
  152. package/dist/ports/artifact-store.d.ts +12 -0
  153. package/dist/ports/artifact-store.js +1 -0
  154. package/dist/ports/clock.d.ts +4 -0
  155. package/dist/ports/clock.js +1 -0
  156. package/dist/ports/generation-meta-writer.d.ts +10 -0
  157. package/dist/ports/generation-meta-writer.js +1 -0
  158. package/dist/ports/generation-prompt.d.ts +19 -0
  159. package/dist/ports/generation-prompt.js +6 -0
  160. package/dist/ports/generation-session-store.d.ts +32 -0
  161. package/dist/ports/generation-session-store.js +26 -0
  162. package/dist/ports/init-prompt.d.ts +8 -0
  163. package/dist/ports/init-prompt.js +6 -0
  164. package/dist/ports/journey-writer.d.ts +10 -0
  165. package/dist/ports/journey-writer.js +1 -0
  166. package/dist/ports/process-runner.d.ts +32 -0
  167. package/dist/ports/process-runner.js +1 -0
  168. package/dist/ports/project-bound-file.d.ts +8 -0
  169. package/dist/ports/project-bound-file.js +1 -0
  170. package/dist/ports/project-file-inspector.d.ts +33 -0
  171. package/dist/ports/project-file-inspector.js +1 -0
  172. package/dist/ports/recorder-prompt.d.ts +29 -0
  173. package/dist/ports/recorder-prompt.js +1 -0
  174. package/dist/ports/skill-installer.d.ts +8 -0
  175. package/dist/ports/skill-installer.js +1 -0
  176. package/package.json +64 -0
@@ -0,0 +1,16 @@
1
+ import { type Journey } from "../../domain/journey.js";
2
+ import type { ProjectBoundJourneyWriterPort } from "../../ports/journey-writer.js";
3
+ import type { ProjectBoundPath } from "../../ports/project-bound-file.js";
4
+ import { type ProjectBoundFileHooks } from "./project-bound-file.js";
5
+ export interface FileSystemJourneyWriterOptions {
6
+ beforeBoundInstall?: ProjectBoundFileHooks["beforeInstall"];
7
+ }
8
+ export declare class FileSystemJourneyWriter implements ProjectBoundJourneyWriterPort {
9
+ private readonly options;
10
+ constructor(options?: FileSystemJourneyWriterOptions);
11
+ write(outputPath: string, input: Journey): Promise<void>;
12
+ writeProjectBound(input: ProjectBoundPath & {
13
+ journey: Journey;
14
+ }): Promise<void>;
15
+ readProjectBound(input: ProjectBoundPath): Promise<Buffer>;
16
+ }
@@ -0,0 +1,34 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { JourneySchema } from "../../domain/journey.js";
5
+ import { readProjectBoundFile, writeProjectBoundText } from "./project-bound-file.js";
6
+ export class FileSystemJourneyWriter {
7
+ options;
8
+ constructor(options = {}) {
9
+ this.options = options;
10
+ }
11
+ async write(outputPath, input) {
12
+ const journey = JourneySchema.parse(input);
13
+ const directory = dirname(outputPath);
14
+ const temporaryPath = join(directory, `.${basename(outputPath)}.${randomUUID()}.tmp`);
15
+ await mkdir(directory, { recursive: true });
16
+ try {
17
+ await writeFile(temporaryPath, `${JSON.stringify(journey, null, 2)}\n`, "utf8");
18
+ await rename(temporaryPath, outputPath);
19
+ }
20
+ catch (error) {
21
+ await rm(temporaryPath, { force: true });
22
+ throw error;
23
+ }
24
+ }
25
+ async writeProjectBound(input) {
26
+ const journey = JourneySchema.parse(input.journey);
27
+ await writeProjectBoundText(input, `${JSON.stringify(journey, null, 2)}\n`, this.options.beforeBoundInstall === undefined
28
+ ? {}
29
+ : { beforeInstall: this.options.beforeBoundInstall });
30
+ }
31
+ async readProjectBound(input) {
32
+ return readProjectBoundFile(input);
33
+ }
34
+ }
@@ -0,0 +1,6 @@
1
+ import type { ProjectBoundPath } from "../../ports/project-bound-file.js";
2
+ export interface ProjectBoundFileHooks {
3
+ beforeInstall?: (() => Promise<void> | void) | undefined;
4
+ }
5
+ export declare function writeProjectBoundText(input: ProjectBoundPath, value: string, hooks?: ProjectBoundFileHooks): Promise<void>;
6
+ export declare function readProjectBoundFile(input: ProjectBoundPath): Promise<Buffer>;
@@ -0,0 +1,240 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants, lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
3
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
4
+ function sameIdentity(left, right) {
5
+ return left.dev === right.dev && left.ino === right.ino;
6
+ }
7
+ function isNodeError(error) {
8
+ return error instanceof Error && "code" in error;
9
+ }
10
+ async function captureDirectory(path) {
11
+ const stats = await lstat(path, { bigint: true });
12
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
13
+ throw new Error(`Project-bound path is not a safe directory: ${path}`);
14
+ }
15
+ return {
16
+ path,
17
+ canonicalPath: await realpath(path),
18
+ dev: stats.dev,
19
+ ino: stats.ino
20
+ };
21
+ }
22
+ async function captureFile(path) {
23
+ const stats = await lstat(path, { bigint: true });
24
+ if (!stats.isFile() || stats.isSymbolicLink()) {
25
+ throw new Error(`Project-bound destination is not a regular file: ${path}`);
26
+ }
27
+ return { dev: stats.dev, ino: stats.ino };
28
+ }
29
+ async function captureOptionalFile(path) {
30
+ try {
31
+ return await captureFile(path);
32
+ }
33
+ catch (error) {
34
+ if (isNodeError(error) && error.code === "ENOENT") {
35
+ return null;
36
+ }
37
+ throw error;
38
+ }
39
+ }
40
+ function relativeInside(root, candidate) {
41
+ const fromRoot = relative(root, candidate);
42
+ if (fromRoot.length === 0
43
+ || fromRoot === ".."
44
+ || fromRoot.startsWith(`..${sep}`)
45
+ || isAbsolute(fromRoot)) {
46
+ throw new Error("Project-bound output escapes the real project root");
47
+ }
48
+ return fromRoot;
49
+ }
50
+ function assertOutsideAuthority(authorityCanonicalPath, candidateCanonicalPath) {
51
+ const fromAuthority = relative(authorityCanonicalPath, candidateCanonicalPath);
52
+ if (fromAuthority.length === 0
53
+ || (fromAuthority !== ".."
54
+ && !fromAuthority.startsWith(`..${sep}`)
55
+ && !isAbsolute(fromAuthority))) {
56
+ throw new Error("Project-bound output overlaps the authority subtree");
57
+ }
58
+ }
59
+ async function verifyDirectory(expected) {
60
+ const current = await captureDirectory(expected.path);
61
+ if (current.canonicalPath !== expected.canonicalPath
62
+ || !sameIdentity(current, expected)) {
63
+ throw new Error(`Project-bound directory identity changed: ${expected.path}`);
64
+ }
65
+ }
66
+ async function verifyBoundOutput(bound) {
67
+ for (const directory of bound.directories) {
68
+ await verifyDirectory(directory);
69
+ }
70
+ await verifyDirectory(bound.authority);
71
+ relativeInside(bound.root.canonicalPath, bound.parent.canonicalPath);
72
+ assertOutsideAuthority(bound.authority.canonicalPath, bound.parent.canonicalPath);
73
+ }
74
+ async function verifyOptionalFile(path, expected) {
75
+ const current = await captureOptionalFile(path);
76
+ if ((current === null) !== (expected === null)
77
+ || (current !== null
78
+ && expected !== null
79
+ && !sameIdentity(current, expected))) {
80
+ throw new Error(`Project-bound destination identity changed: ${path}`);
81
+ }
82
+ }
83
+ async function syncDirectory(path) {
84
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
85
+ try {
86
+ await handle.sync();
87
+ }
88
+ finally {
89
+ await handle.close();
90
+ }
91
+ }
92
+ function lexicalSegments(root, output) {
93
+ const fromRoot = relative(root, output);
94
+ if (fromRoot.length === 0
95
+ || fromRoot === ".."
96
+ || fromRoot.startsWith(`..${sep}`)
97
+ || isAbsolute(fromRoot)) {
98
+ throw new Error("Project-bound output must stay within the project");
99
+ }
100
+ const segments = fromRoot.split(sep);
101
+ if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
102
+ throw new Error("Project-bound output path must be normalized");
103
+ }
104
+ return segments;
105
+ }
106
+ async function prepareBoundOutput(input, createParents) {
107
+ const projectPath = resolve(input.projectRoot);
108
+ const outputPath = resolve(input.outputPath);
109
+ const authorityPath = resolve(input.authorityRoot);
110
+ const segments = lexicalSegments(projectPath, outputPath);
111
+ const root = await captureDirectory(projectPath);
112
+ const authority = await captureDirectory(authorityPath);
113
+ relativeInside(root.canonicalPath, authority.canonicalPath);
114
+ const authorityFromRoot = relative(root.canonicalPath, authority.canonicalPath);
115
+ if (authorityFromRoot === ".."
116
+ || authorityFromRoot.startsWith(`..${sep}`)
117
+ || isAbsolute(authorityFromRoot)) {
118
+ throw new Error("Authority root escapes the real project root");
119
+ }
120
+ let current = projectPath;
121
+ const directories = [root];
122
+ for (const segment of segments.slice(0, -1)) {
123
+ const next = resolve(current, segment);
124
+ try {
125
+ const captured = await captureDirectory(next);
126
+ relativeInside(root.canonicalPath, captured.canonicalPath);
127
+ assertOutsideAuthority(authority.canonicalPath, captured.canonicalPath);
128
+ directories.push(captured);
129
+ }
130
+ catch (error) {
131
+ if (!createParents
132
+ || !isNodeError(error)
133
+ || error.code !== "ENOENT") {
134
+ throw error;
135
+ }
136
+ await verifyDirectory(directories.at(-1));
137
+ await mkdir(next);
138
+ await syncDirectory(current);
139
+ const captured = await captureDirectory(next);
140
+ relativeInside(root.canonicalPath, captured.canonicalPath);
141
+ assertOutsideAuthority(authority.canonicalPath, captured.canonicalPath);
142
+ directories.push(captured);
143
+ }
144
+ current = next;
145
+ }
146
+ const parent = directories.at(-1);
147
+ if (parent === undefined) {
148
+ throw new Error("Project-bound output parent is unavailable");
149
+ }
150
+ const bound = {
151
+ root,
152
+ authority,
153
+ parent,
154
+ directories,
155
+ outputPath
156
+ };
157
+ await verifyBoundOutput(bound);
158
+ return bound;
159
+ }
160
+ async function safeTemporaryCleanup(bound, temporaryPath, expected) {
161
+ if (expected === undefined) {
162
+ return;
163
+ }
164
+ try {
165
+ await verifyBoundOutput(bound);
166
+ const current = await captureFile(temporaryPath);
167
+ if (sameIdentity(current, expected)) {
168
+ await unlink(temporaryPath);
169
+ await syncDirectory(bound.parent.path);
170
+ }
171
+ }
172
+ catch {
173
+ // Never follow a substituted path to clean up.
174
+ }
175
+ }
176
+ export async function writeProjectBoundText(input, value, hooks = {}) {
177
+ const bound = await prepareBoundOutput(input, true);
178
+ const originalDestination = await captureOptionalFile(bound.outputPath);
179
+ const temporaryPath = resolve(bound.parent.path, `.${basename(bound.outputPath)}.${randomUUID()}.tmp`);
180
+ let temporaryIdentity;
181
+ let handle;
182
+ try {
183
+ handle = await open(temporaryPath, constants.O_WRONLY
184
+ | constants.O_CREAT
185
+ | constants.O_EXCL
186
+ | constants.O_NOFOLLOW, 0o600);
187
+ await handle.writeFile(value, "utf8");
188
+ await handle.sync();
189
+ const stats = await handle.stat({ bigint: true });
190
+ temporaryIdentity = { dev: stats.dev, ino: stats.ino };
191
+ await handle.close();
192
+ handle = undefined;
193
+ await hooks.beforeInstall?.();
194
+ await verifyBoundOutput(bound);
195
+ await verifyOptionalFile(bound.outputPath, originalDestination);
196
+ const stagedIdentity = await captureFile(temporaryPath);
197
+ if (!sameIdentity(stagedIdentity, temporaryIdentity)) {
198
+ throw new Error("Project-bound temporary file identity changed");
199
+ }
200
+ await rename(temporaryPath, bound.outputPath);
201
+ const installedIdentity = await captureFile(bound.outputPath);
202
+ if (!sameIdentity(installedIdentity, temporaryIdentity)) {
203
+ throw new Error("Project-bound installed file identity changed");
204
+ }
205
+ await syncDirectory(bound.parent.path);
206
+ }
207
+ catch (error) {
208
+ if (handle !== undefined) {
209
+ await handle.close().catch(() => undefined);
210
+ }
211
+ await safeTemporaryCleanup(bound, temporaryPath, temporaryIdentity);
212
+ throw error;
213
+ }
214
+ }
215
+ export async function readProjectBoundFile(input) {
216
+ const bound = await prepareBoundOutput(input, false);
217
+ const handle = await open(bound.outputPath, constants.O_RDONLY | constants.O_NOFOLLOW);
218
+ try {
219
+ const stats = await handle.stat({ bigint: true });
220
+ if (!stats.isFile()) {
221
+ throw new Error("Project-bound output is not a regular file");
222
+ }
223
+ const openedIdentity = { dev: stats.dev, ino: stats.ino };
224
+ await verifyBoundOutput(bound);
225
+ const pathIdentity = await captureFile(bound.outputPath);
226
+ if (!sameIdentity(openedIdentity, pathIdentity)) {
227
+ throw new Error("Project-bound output identity changed");
228
+ }
229
+ const bytes = await handle.readFile();
230
+ await verifyBoundOutput(bound);
231
+ const finalIdentity = await captureFile(bound.outputPath);
232
+ if (!sameIdentity(openedIdentity, finalIdentity)) {
233
+ throw new Error("Project-bound output identity changed during read");
234
+ }
235
+ return bytes;
236
+ }
237
+ finally {
238
+ await handle.close();
239
+ }
240
+ }
@@ -0,0 +1,8 @@
1
+ import { type ProjectFileInspection, type ProjectFileInspector } from "../../ports/project-file-inspector.js";
2
+ export declare class NodeProjectFileInspector implements ProjectFileInspector {
3
+ readonly inspectProjectFile: (input: {
4
+ projectRoot: string;
5
+ relativePath: string;
6
+ maximumBytes: number;
7
+ }) => Promise<ProjectFileInspection>;
8
+ }
@@ -0,0 +1,198 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { open, realpath, stat } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5
+ function errorCode(error) {
6
+ return (typeof error === "object"
7
+ && error !== null
8
+ && "code" in error
9
+ && typeof error.code === "string")
10
+ ? error.code
11
+ : undefined;
12
+ }
13
+ function isContained(root, path) {
14
+ const fromRoot = relative(root, path);
15
+ return (fromRoot === ""
16
+ || (fromRoot !== ".."
17
+ && !fromRoot.startsWith(`..${sep}`)
18
+ && !isAbsolute(fromRoot)));
19
+ }
20
+ function sameIdentity(left, right) {
21
+ return (left.dev === right.dev
22
+ && left.ino === right.ino
23
+ && left.size === right.size
24
+ && left.mtimeNs === right.mtimeNs
25
+ && left.ctimeNs === right.ctimeNs);
26
+ }
27
+ async function fileIdentity(handle) {
28
+ const fileStats = await handle.stat({ bigint: true });
29
+ return {
30
+ dev: fileStats.dev,
31
+ ino: fileStats.ino,
32
+ size: fileStats.size,
33
+ mtimeNs: fileStats.mtimeNs,
34
+ ctimeNs: fileStats.ctimeNs
35
+ };
36
+ }
37
+ async function pathIdentity(path) {
38
+ const fileStats = await stat(path, { bigint: true });
39
+ return {
40
+ dev: fileStats.dev,
41
+ ino: fileStats.ino,
42
+ size: fileStats.size,
43
+ mtimeNs: fileStats.mtimeNs,
44
+ ctimeNs: fileStats.ctimeNs
45
+ };
46
+ }
47
+ function normalizedRelativePath(root, path) {
48
+ return relative(root, path).replaceAll("\\", "/");
49
+ }
50
+ function evidenceFailure(error) {
51
+ return errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR"
52
+ ? { status: "notFound" }
53
+ : { status: "unreadable" };
54
+ }
55
+ export class NodeProjectFileInspector {
56
+ inspectProjectFile = async (input) => {
57
+ let canonicalRoot;
58
+ try {
59
+ canonicalRoot = await realpath(input.projectRoot);
60
+ }
61
+ catch (error) {
62
+ return errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR"
63
+ ? { status: "rootNotFound" }
64
+ : { status: "rootUnreadable" };
65
+ }
66
+ let rootBefore;
67
+ try {
68
+ const rootStats = await stat(canonicalRoot, { bigint: true });
69
+ if (!rootStats.isDirectory()) {
70
+ return { status: "rootNotDirectory" };
71
+ }
72
+ rootBefore = {
73
+ dev: rootStats.dev,
74
+ ino: rootStats.ino,
75
+ size: rootStats.size,
76
+ mtimeNs: rootStats.mtimeNs,
77
+ ctimeNs: rootStats.ctimeNs
78
+ };
79
+ }
80
+ catch (error) {
81
+ return errorCode(error) === "ENOENT"
82
+ ? { status: "rootNotFound" }
83
+ : { status: "rootUnreadable" };
84
+ }
85
+ const normalizedPath = input.relativePath.replaceAll("\\", "/");
86
+ if (normalizedPath.startsWith("/")
87
+ || /^[A-Za-z]:/.test(normalizedPath)
88
+ || normalizedPath.split("/").includes("..")) {
89
+ return { status: "escape" };
90
+ }
91
+ const candidate = resolve(canonicalRoot, normalizedPath);
92
+ if (!isContained(canonicalRoot, candidate)) {
93
+ return { status: "escape" };
94
+ }
95
+ let canonicalBefore;
96
+ let pathBefore;
97
+ try {
98
+ canonicalBefore = await realpath(candidate);
99
+ if (!isContained(canonicalRoot, canonicalBefore)) {
100
+ return { status: "escape" };
101
+ }
102
+ pathBefore = await pathIdentity(canonicalBefore);
103
+ }
104
+ catch (error) {
105
+ return evidenceFailure(error);
106
+ }
107
+ const resolvedRelativePath = normalizedRelativePath(canonicalRoot, canonicalBefore);
108
+ const openFlags = process.platform === "win32"
109
+ ? constants.O_RDONLY
110
+ : constants.O_RDONLY | constants.O_NOFOLLOW;
111
+ let handle;
112
+ try {
113
+ handle = await open(canonicalBefore, openFlags);
114
+ }
115
+ catch (error) {
116
+ return errorCode(error) === "ELOOP"
117
+ ? { status: "changedIdentity", resolvedRelativePath }
118
+ : evidenceFailure(error);
119
+ }
120
+ try {
121
+ const openedBefore = await fileIdentity(handle);
122
+ if (!sameIdentity(pathBefore, openedBefore)) {
123
+ return { status: "changedIdentity", resolvedRelativePath };
124
+ }
125
+ const openedStats = await handle.stat({ bigint: true });
126
+ if (!openedStats.isFile()) {
127
+ return { status: "notFile", resolvedRelativePath };
128
+ }
129
+ try {
130
+ const canonicalOpened = await realpath(candidate);
131
+ const rootOpened = await realpath(input.projectRoot);
132
+ const pathOpened = await pathIdentity(canonicalOpened);
133
+ if (canonicalOpened !== canonicalBefore
134
+ || rootOpened !== canonicalRoot
135
+ || !isContained(canonicalRoot, canonicalOpened)
136
+ || !sameIdentity(openedBefore, pathOpened)) {
137
+ return { status: "changedIdentity", resolvedRelativePath };
138
+ }
139
+ }
140
+ catch {
141
+ return { status: "changedIdentity", resolvedRelativePath };
142
+ }
143
+ const hash = createHash("sha256");
144
+ const buffer = Buffer.allocUnsafe(64 * 1024);
145
+ let totalBytes = 0;
146
+ let tooLarge = false;
147
+ while (totalBytes <= input.maximumBytes) {
148
+ const remaining = input.maximumBytes + 1 - totalBytes;
149
+ const bytesToRead = Math.min(buffer.byteLength, remaining);
150
+ const readResult = await handle.read(buffer, 0, bytesToRead, null);
151
+ if (readResult.bytesRead === 0) {
152
+ break;
153
+ }
154
+ totalBytes += readResult.bytesRead;
155
+ hash.update(buffer.subarray(0, readResult.bytesRead));
156
+ if (totalBytes > input.maximumBytes) {
157
+ tooLarge = true;
158
+ break;
159
+ }
160
+ }
161
+ const openedAfter = await fileIdentity(handle);
162
+ let canonicalAfter;
163
+ let rootCanonicalAfter;
164
+ let pathAfter;
165
+ let rootAfter;
166
+ try {
167
+ canonicalAfter = await realpath(candidate);
168
+ rootCanonicalAfter = await realpath(input.projectRoot);
169
+ pathAfter = await pathIdentity(canonicalAfter);
170
+ rootAfter = await pathIdentity(canonicalRoot);
171
+ }
172
+ catch {
173
+ return { status: "changedIdentity", resolvedRelativePath };
174
+ }
175
+ if (canonicalAfter !== canonicalBefore
176
+ || rootCanonicalAfter !== canonicalRoot
177
+ || !isContained(canonicalRoot, canonicalAfter)
178
+ || !sameIdentity(openedBefore, openedAfter)
179
+ || !sameIdentity(openedAfter, pathAfter)
180
+ || !sameIdentity(rootBefore, rootAfter)) {
181
+ return { status: "changedIdentity", resolvedRelativePath };
182
+ }
183
+ return tooLarge
184
+ ? { status: "tooLarge", resolvedRelativePath }
185
+ : {
186
+ status: "inspected",
187
+ resolvedRelativePath,
188
+ sha256: hash.digest("hex")
189
+ };
190
+ }
191
+ catch {
192
+ return { status: "unreadable" };
193
+ }
194
+ finally {
195
+ await handle.close().catch(() => undefined);
196
+ }
197
+ };
198
+ }
@@ -0,0 +1,10 @@
1
+ import type { SkillInstallResult, SkillInstallerPort } from "../../ports/skill-installer.js";
2
+ export declare class SkillPayloadMissingError extends Error {
3
+ readonly payloadPath: string;
4
+ readonly name = "SkillPayloadMissingError";
5
+ constructor(payloadPath: string);
6
+ }
7
+ export declare class FileSystemSkillInstaller implements SkillInstallerPort {
8
+ findPayload(): Promise<string>;
9
+ installTo(targetDir: string): Promise<SkillInstallResult>;
10
+ }
@@ -0,0 +1,39 @@
1
+ import { access, cp, mkdir } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { SKILL_DIRECTORY_NAME } from "../../domain/init.js";
6
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
7
+ const PAYLOAD_PATH = resolve(PACKAGE_ROOT, "assets", "skills", SKILL_DIRECTORY_NAME);
8
+ export class SkillPayloadMissingError extends Error {
9
+ payloadPath;
10
+ name = "SkillPayloadMissingError";
11
+ constructor(payloadPath) {
12
+ super(`Skill payload not found at ${payloadPath}`);
13
+ this.payloadPath = payloadPath;
14
+ }
15
+ }
16
+ export class FileSystemSkillInstaller {
17
+ async findPayload() {
18
+ try {
19
+ await access(PAYLOAD_PATH, constants.R_OK);
20
+ }
21
+ catch {
22
+ throw new SkillPayloadMissingError(PAYLOAD_PATH);
23
+ }
24
+ return PAYLOAD_PATH;
25
+ }
26
+ async installTo(targetDir) {
27
+ const payloadPath = await this.findPayload();
28
+ const resolvedTarget = resolve(targetDir);
29
+ if (resolvedTarget === resolve(payloadPath)) {
30
+ return { targetPath: resolvedTarget, skipped: true };
31
+ }
32
+ await mkdir(dirname(resolvedTarget), { recursive: true });
33
+ await cp(payloadPath, resolvedTarget, {
34
+ recursive: true,
35
+ force: true
36
+ });
37
+ return { targetPath: resolvedTarget, skipped: false };
38
+ }
39
+ }
@@ -0,0 +1,8 @@
1
+ import type { CommandResult, CommandSpec, ProcessRunner, RunningCommand, StreamHandlers } from "../../ports/process-runner.js";
2
+ export declare class NodeProcessRunner implements ProcessRunner {
3
+ private readonly defaultTimeoutMs;
4
+ private readonly streamStartupGraceMs;
5
+ constructor(defaultTimeoutMs?: number, streamStartupGraceMs?: number);
6
+ run(spec: CommandSpec): Promise<CommandResult>;
7
+ start(spec: CommandSpec, handlers?: StreamHandlers): RunningCommand;
8
+ }