-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathWorkspace.php
More file actions
214 lines (182 loc) · 7.15 KB
/
Copy pathWorkspace.php
File metadata and controls
214 lines (182 loc) · 7.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
<?php
declare(strict_types=1);
namespace JesseGall\CodeCommandments;
use JesseGall\CodeCommandments\Support\Directory;
use JesseGall\CodeCommandments\Support\PhpFile;
/**
* The ONE home of the `.commandments/` layout — every artifact path in the package is built here.
* Two tiers: the durable tier ({@see shared} — `config.php`, `repent.php`, `.gitignore`, the vue-tsc
* cache) stays flat, and the session tier ({@see path} — counters, plan markers, the sins checklist)
* is scoped into `.commandments/sessions/<key>/` so concurrent sessions never overwrite each other.
* The key is a 5-char hash of the session id: an explicit id (a hook payload, via
* {@see Hooks\HookEvent::workspace}) → the `CLAUDE_CODE_SESSION_ID` env var (exported to every shell
* command, so the CLI lands in the SAME folder as the hooks) → `default` for a bare terminal/CI run.
* {@see prune} sweeps stale session folders on a fresh session start.
*/
final class Workspace
{
private const string DIR = '.commandments';
private const string SESSIONS = 'sessions';
/**
* The durable-tier folder a PROJECT writes its own code into — its custom detectors, sins,
* skills and packages ({@see custom}). The one folder under `.commandments/` that is neither
* generated nor session-scoped, so it is the one (besides `config.php`) kept out of the
* folder's `.gitignore`.
*/
public const string CUSTOM = 'custom';
/**
* Where the published skills REALLY live, relative to the project root — the one library every
* agent reads, directly or through a link of its own. `.agents/skills` is the cross-agent
* location rather than any one assistant's folder, so the agent that reads it natively needs no
* link at all and the rest get one.
*/
public const string LIBRARY = '.agents/skills';
/**
* The session folder for a run with no session id at all (a human terminal, CI).
*/
public const string DEFAULT_SESSION = 'default';
/**
* How many hex chars of the hashed session id name the folder — short but collision-safe in practice.
*/
private const int KEY_LENGTH = 5;
/**
* How long a sibling session folder may go untouched before {@see prune} sweeps it.
*/
private const int PRUNE_DAYS = 7;
public function __construct(
private readonly string $root,
private readonly ?string $sessionId = null,
) {}
/**
* The workspace for $root, resolving the session id when the caller has none: an explicit
* $sessionId (a hook payload) wins, else the `CLAUDE_CODE_SESSION_ID` env var, else none
* (→ the {@see DEFAULT_SESSION} folder).
*/
public static function at(string $root, ?string $sessionId = null): self
{
if ($sessionId !== null && $sessionId !== '') {
return new self($root, $sessionId);
}
return new self($root, getenv('CLAUDE_CODE_SESSION_ID') ?: null);
}
/**
* The project's hand-written `config.php` — THE durable-tier file (default the cwd). One home,
* so every consumer (`Config::load`, the config scribes, the disable menu) resolves the same path.
*/
public static function config(?string $dir = null): string
{
return self::at($dir ?? getcwd())->shared('config.php');
}
/**
* The project's `custom/` folder — `<dir>/.commandments/custom` (default the cwd), where a
* project keeps its OWN detectors, sins, skills and packages. One home, so the scaffolder
* ({@see Cli\Make\Make}), the loader ({@see Config::load}) and the publisher all resolve it
* the same way.
*/
public static function custom(?string $dir = null): string
{
return self::at($dir ?? getcwd())->shared(self::CUSTOM);
}
/**
* Every PHP file a project has written into its {@see custom} folder, recursively and in a
* stable order — the classes {@see Config::load} requires before the config composes, so a
* `->detector(...)` line can name a class no autoloader knows about.
*
* @return list<string>
*/
public static function customFiles(?string $dir = null): array
{
$root = self::custom($dir);
if (! is_dir($root)) {
return [];
}
$files = [];
/**
* @var \SplFileInfo $file
*/
foreach (new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
) as $file) {
if (PhpFile::is($file)) {
$files[] = $file->getPathname();
}
}
sort($files);
return $files;
}
public function root(): string
{
return $this->root;
}
/**
* The skill library's absolute path — `<root>/.agents/skills`. It sits OUTSIDE `.commandments/`
* because it is not our workspace: it is a directory the agents themselves read, which a project
* may also keep hand-written skills in. We own only the entries we published there.
*/
public function library(): string
{
return $this->root . '/' . self::LIBRARY;
}
/**
* The 5-char folder name for this session — `substr(sha1(id), 0, 5)`, or `default` without an id.
*/
public function sessionKey(): string
{
return $this->sessionId === null
? self::DEFAULT_SESSION
: substr(sha1($this->sessionId), 0, self::KEY_LENGTH);
}
/**
* The durable tier: `<root>/.commandments`.
*/
public function dir(): string
{
return $this->root . '/' . self::DIR;
}
/**
* This session's folder: `<root>/.commandments/sessions/<key>`.
*/
public function sessionDir(): string
{
return $this->dir() . '/' . self::SESSIONS . '/' . $this->sessionKey();
}
/**
* A session-scoped state file: `<root>/.commandments/sessions/<key>/<file>`.
*/
public function path(string $file): string
{
return $this->sessionDir() . '/' . $file;
}
/**
* A durable-tier file at the `.commandments/` root (`config.php`, `repent.php`, …) — the files
* that must NOT be session-scoped.
*/
public function shared(string $file): string
{
return $this->dir() . '/' . $file;
}
/**
* The session-scoped path WITHOUT the root — `.commandments/sessions/<key>/<file>` — for display
* strings and cwd-relative consumers.
*/
public function relative(string $file): string
{
return self::DIR . '/' . self::SESSIONS . '/' . $this->sessionKey() . '/' . $file;
}
/**
* Sweep stale sibling session folders — any `sessions/<key>` dir untouched for $days (default
* {@see PRUNE_DAYS}), except this session's own. Never touches the durable tier.
*/
public function prune(int $days = self::PRUNE_DAYS): void
{
$cutoff = time() - $days * 86400;
foreach (glob($this->dir() . '/' . self::SESSIONS . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
if ($dir === $this->sessionDir()) {
continue;
}
if ((filemtime($dir) ?: 0) < $cutoff) {
Directory::delete($dir);
}
}
}
}