@indigoai-us/hq-cli 5.316.0 → 5.318.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/assets/scaffold/core/scripts/hq-session.sh +3 -3
- package/assets/scaffold/core/scripts/session-journal.sh +301 -0
- package/assets/scaffold/core/scripts/session-project.sh +800 -0
- package/assets/scaffold/core/scripts/session-title-config.sh +76 -0
- package/assets/scaffold/core/scripts/session-title.sh +305 -0
- package/dist/command-catalog.generated.d.ts +52 -0
- package/dist/command-catalog.generated.js +64 -0
- package/dist/commands/core-hq-session-native.js +3 -2
- package/dist/commands/core-session-helper-runtime.d.ts +10 -0
- package/dist/commands/core-session-helper-runtime.js +7 -0
- package/dist/commands/core-session-journal-native.d.ts +4 -0
- package/dist/commands/core-session-journal-native.js +327 -0
- package/dist/commands/core-session-project-native.d.ts +3 -0
- package/dist/commands/core-session-project-native.js +855 -0
- package/dist/commands/core-session-title-config-native.d.ts +4 -0
- package/dist/commands/core-session-title-config-native.js +77 -0
- package/dist/commands/core-session-title-native.d.ts +3 -0
- package/dist/commands/core-session-title-native.js +306 -0
- package/dist/commands/core.js +61 -0
- package/dist/fast-scaffold.js +16 -0
- package/dist/lib/lanes/autopilot.d.ts +7 -0
- package/dist/lib/lanes/autopilot.js +22 -2
- package/dist/utils/version-gate.js +8 -7
- package/package.json +1 -1
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# session-project.sh - create or reuse a lightweight project folder for native sessions.
|
|
3
|
+
#
|
|
4
|
+
# This is intentionally thinner than /plan. It gives native Claude/Codex work a
|
|
5
|
+
# durable project/prd.json target without forcing a full interview flow.
|
|
6
|
+
|
|
7
|
+
set -uo pipefail
|
|
8
|
+
|
|
9
|
+
HQ_ROOT="${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}}"
|
|
10
|
+
SESSION_PROJECT_STDIN_INPUT=""
|
|
11
|
+
if [ "${1:-}" = "ingest-plan" ]; then
|
|
12
|
+
SESSION_PROJECT_STDIN_INPUT="$(cat 2>/dev/null || true)"
|
|
13
|
+
fi
|
|
14
|
+
export SESSION_PROJECT_STDIN_INPUT
|
|
15
|
+
|
|
16
|
+
command -v node >/dev/null 2>&1 || { echo "session-project: node is required" >&2; exit 1; }
|
|
17
|
+
|
|
18
|
+
node - "$HQ_ROOT" "$@" <<'JS'
|
|
19
|
+
const fs = require("fs");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
|
|
22
|
+
const HQ_ROOT = fs.realpathSync(process.argv[2]);
|
|
23
|
+
const ARGS = process.argv.slice(3);
|
|
24
|
+
|
|
25
|
+
// Words that must never, on their own, make two pieces of work "the same
|
|
26
|
+
// project". The original list held 48 entries and omitted the vocabulary that
|
|
27
|
+
// actually recurs in engineering prompts, so slugs built from ordinary
|
|
28
|
+
// instructions ("not-ask-changes-pr-revert", "many-folders-root-hq-not")
|
|
29
|
+
// became attractors that swallowed hundreds of unrelated sessions.
|
|
30
|
+
const STOPWORDS = new Set([
|
|
31
|
+
"about", "after", "again", "almost", "always", "and", "any", "are",
|
|
32
|
+
"basically", "before", "being", "can", "claude", "codex", "create",
|
|
33
|
+
"created", "creating", "default", "does", "doing", "done", "for",
|
|
34
|
+
"from", "have", "how", "into", "mode", "native", "ones", "plan",
|
|
35
|
+
"project", "projects", "session", "sessions", "should", "that",
|
|
36
|
+
"the", "this", "update", "updated", "when", "with", "work", "would",
|
|
37
|
+
// Instruction scaffolding and generic engineering verbs/nouns. These carry
|
|
38
|
+
// no signal about *which* piece of work a session belongs to.
|
|
39
|
+
"actually", "add", "added", "adding", "all", "also", "back", "but",
|
|
40
|
+
"change", "changed", "changes", "check", "checked", "checking", "could",
|
|
41
|
+
"current", "currently", "error", "errors", "failed", "failing", "file",
|
|
42
|
+
"files", "find", "fix", "fixed", "fixes", "fixing", "get", "got", "here",
|
|
43
|
+
"issue", "issues", "just", "keep", "let", "lets", "like", "look", "looking",
|
|
44
|
+
"looks", "made", "make", "makes", "making", "more", "need", "needs", "new",
|
|
45
|
+
"not", "now", "old", "only", "our", "out", "over", "please", "put", "report",
|
|
46
|
+
"reports", "root", "run", "running", "runs", "same", "see", "some", "still",
|
|
47
|
+
"sure", "take", "test", "tested", "testing", "tests", "than", "them", "then",
|
|
48
|
+
"there", "they", "thing", "things", "try", "trying", "use", "used", "using",
|
|
49
|
+
"very", "want", "wants", "was", "way", "were", "what", "where", "which",
|
|
50
|
+
"who", "why", "will", "your",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
// Generic topical words that show up in almost every PRD description. They
|
|
54
|
+
// must not count toward reuse when they appear only in project prose — a
|
|
55
|
+
// slug token with the same spelling can still match, so "content workbench
|
|
56
|
+
// platform" can reuse that project, while "scott content review" cannot.
|
|
57
|
+
const MATCH_NOISE = new Set([
|
|
58
|
+
"analysis", "article", "articles", "background", "brainstorm", "client",
|
|
59
|
+
"clients", "company", "content", "context", "data", "document", "documents",
|
|
60
|
+
"draft", "followup", "information", "linkedin", "management", "notes",
|
|
61
|
+
"personal", "research", "review", "summary", "workflow",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
// A project that has already absorbed this many sessions is an attractor, not
|
|
65
|
+
// a match. Past this point only a strong slug match may add to it.
|
|
66
|
+
const MAX_REUSE_SESSIONS = 25;
|
|
67
|
+
// Fraction of a project's own slug words the query must cover to override the
|
|
68
|
+
// saturation breaker above. Unattended sessions use the same bar to reuse.
|
|
69
|
+
const STRONG_SLUG_COVERAGE = 0.6;
|
|
70
|
+
// Winner must beat the runner-up by this much or we create a new folder
|
|
71
|
+
// rather than guess. Wrong reuse is worse than a new thin project.
|
|
72
|
+
const REUSE_MARGIN = 1;
|
|
73
|
+
|
|
74
|
+
const pad = (x) => String(x).padStart(2, "0");
|
|
75
|
+
function nowIso() {
|
|
76
|
+
const d = new Date();
|
|
77
|
+
return d.getUTCFullYear() + "-" + pad(d.getUTCMonth() + 1) + "-" + pad(d.getUTCDate()) +
|
|
78
|
+
"T" + pad(d.getUTCHours()) + ":" + pad(d.getUTCMinutes()) + ":" + pad(d.getUTCSeconds()) + "Z";
|
|
79
|
+
}
|
|
80
|
+
function today() {
|
|
81
|
+
const d = new Date();
|
|
82
|
+
return d.getUTCFullYear() + "-" + pad(d.getUTCMonth() + 1) + "-" + pad(d.getUTCDate());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function slugify(value) {
|
|
86
|
+
value = (value || "native-session").toLowerCase();
|
|
87
|
+
value = value.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
88
|
+
value = value.replace(/-+/g, "-");
|
|
89
|
+
value = (value || "native-session").slice(0, 60).replace(/^-+|-+$/g, "");
|
|
90
|
+
return value || "native-session";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Filler words that should never anchor a project name — approvals,
|
|
94
|
+
// pleasantries, pronouns, and instruction scaffolding. Distinct from STOPWORDS
|
|
95
|
+
// (which tunes reuse-matching); this set tunes the human-facing slug.
|
|
96
|
+
const SLUG_FILLER = new Set([
|
|
97
|
+
"ok", "okay", "yes", "yep", "yeah", "ya", "sure", "cool", "nice", "great",
|
|
98
|
+
"good", "perfect", "thanks", "thank", "you", "your", "please", "pls", "go",
|
|
99
|
+
"ahead", "for", "it", "do", "did", "that", "this", "now", "lets", "let",
|
|
100
|
+
"us", "proceed", "continue", "just", "still", "also", "and", "then", "the",
|
|
101
|
+
"a", "an", "to", "with", "up", "on", "in", "of", "both", "all", "sounds",
|
|
102
|
+
"lgtm", "fine", "right", "exactly", "agreed", "next", "keep", "again",
|
|
103
|
+
"more", "im", "i", "we", "should", "can", "could", "would", "want", "need",
|
|
104
|
+
"me", "my", "our", "help", "make", "get", "got", "have", "is", "are", "be",
|
|
105
|
+
"out", "here", "there", "some", "any", "as", "at", "by", "or", "but", "so",
|
|
106
|
+
"from", "into", "about", "kindly", "gonna", "wanna", "like",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
// Build a clean, meaningful project slug: drop filler, keep the first few
|
|
110
|
+
// content words. Date-stamp as a last resort so a name is always produced.
|
|
111
|
+
function topicSlug(text, maxWords = 5) {
|
|
112
|
+
const toks = ((text || "").toLowerCase().match(/[a-z0-9][a-z0-9-]*/g)) || [];
|
|
113
|
+
const content = toks.filter((t) => !SLUG_FILLER.has(t) && !/^[0-9]+$/.test(t) && t.length > 1);
|
|
114
|
+
if (!content.length) return "session-" + today();
|
|
115
|
+
return slugify(content.slice(0, maxWords).join("-"));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function words(value, extraSkip) {
|
|
119
|
+
const found = ((value || "").toLowerCase().match(/[a-z0-9][a-z0-9-]{2,}/g)) || [];
|
|
120
|
+
const out = new Set();
|
|
121
|
+
for (const w of found) {
|
|
122
|
+
if (STOPWORDS.has(w)) continue;
|
|
123
|
+
if (extraSkip && extraSkip.has(w)) continue;
|
|
124
|
+
out.add(w);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function unionSets(...sets) {
|
|
130
|
+
const out = new Set();
|
|
131
|
+
for (const s of sets) {
|
|
132
|
+
if (!s) continue;
|
|
133
|
+
for (const w of s) out.add(w);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let _identityWords = null;
|
|
139
|
+
function ownerIdentityWords() {
|
|
140
|
+
if (_identityWords) return _identityWords;
|
|
141
|
+
const out = new Set();
|
|
142
|
+
const files = [
|
|
143
|
+
path.join(HQ_ROOT, "personal", "agents-profile.md"),
|
|
144
|
+
path.join(HQ_ROOT, "personal", "profile.md"),
|
|
145
|
+
path.join(HQ_ROOT, "agents-profile.md"),
|
|
146
|
+
];
|
|
147
|
+
for (const p of files) {
|
|
148
|
+
let text;
|
|
149
|
+
try { text = fs.readFileSync(p, "utf8"); } catch (e) { continue; }
|
|
150
|
+
for (const line of text.split("\n").slice(0, 40)) {
|
|
151
|
+
let name = "";
|
|
152
|
+
const heading = line.match(/^#\s+(.+?)(?:\s+-\s+Profile)?\s*$/i);
|
|
153
|
+
if (heading) name = heading[1];
|
|
154
|
+
const labeled = line.match(/^(?:name|owner|full name)\s*[:\-]\s*(.+)$/i);
|
|
155
|
+
if (labeled) name = labeled[1];
|
|
156
|
+
if (!name) continue;
|
|
157
|
+
name = name.replace(/\(.*?\)/g, " ");
|
|
158
|
+
for (const t of (name.toLowerCase().match(/[a-z][a-z-]{2,}/g) || [])) {
|
|
159
|
+
if (!STOPWORDS.has(t) && t !== "profile") out.add(t);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
_identityWords = out;
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function truthyFlag(value) {
|
|
168
|
+
return /^(1|true|yes|on)$/i.test(String(value || "").trim());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sessionIsUnattended(args) {
|
|
172
|
+
if (args && (args.unattended === true || truthyFlag(args.unattended))) return true;
|
|
173
|
+
if (truthyFlag(process.env.HQ_SESSION_UNATTENDED) || truthyFlag(process.env.HQ_UNATTENDED)) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
const origin = String((args && args.origin) || "");
|
|
177
|
+
return /\b(scheduled|checkpoint|cron|job|background|unattended|maintenance)\b/i.test(origin);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function slugCoverage(slugTokens, queryWords) {
|
|
181
|
+
if (!slugTokens.size) return 0;
|
|
182
|
+
let hits = 0;
|
|
183
|
+
for (const w of slugTokens) if (queryWords.has(w)) hits += 1;
|
|
184
|
+
return hits / slugTokens.size;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Slug words, split on the hyphen boundary. `words()` keeps hyphens inside a
|
|
188
|
+
// token, so a directory name like "a-b-c" is one opaque token to it; matching
|
|
189
|
+
// needs the individual words. Deliberately NOT a substring test — the previous
|
|
190
|
+
// rule was `name.includes(word)`, which scored "git" against "github" and gave
|
|
191
|
+
// long slugs a large accidental match surface.
|
|
192
|
+
function slugWords(name, extraSkip) {
|
|
193
|
+
const out = new Set();
|
|
194
|
+
for (const t of (((name || "").toLowerCase().match(/[a-z0-9]+/g)) || [])) {
|
|
195
|
+
if (t.length < 3 || STOPWORDS.has(t)) continue;
|
|
196
|
+
if (extraSkip && extraSkip.has(t)) continue;
|
|
197
|
+
out.add(t);
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Returns null for an absent file. An unreadable or unparseable file is
|
|
203
|
+
// reported and skipped — never silently treated as an empty project, because
|
|
204
|
+
// callers that write the result back would then destroy its contents.
|
|
205
|
+
function readJson(p) {
|
|
206
|
+
try {
|
|
207
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
208
|
+
} catch (e) {
|
|
209
|
+
if (e && e.code === "ENOENT") return null;
|
|
210
|
+
process.stderr.write("session-project: skipping unreadable " + p + ": " +
|
|
211
|
+
(e && e.message ? e.message : String(e)) + "\n");
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Strict variant for the read-modify-write paths. Absent is fine (null);
|
|
217
|
+
// present-but-corrupt is fatal, so a temporarily unreadable prd.json — which
|
|
218
|
+
// is exactly what a sync conflict produces — is never overwritten with a stub.
|
|
219
|
+
function readJsonForWrite(p) {
|
|
220
|
+
let raw;
|
|
221
|
+
try {
|
|
222
|
+
raw = fs.readFileSync(p, "utf8");
|
|
223
|
+
} catch (e) {
|
|
224
|
+
if (e && e.code === "ENOENT") return null;
|
|
225
|
+
die("cannot read " + p + ": " + (e && e.message ? e.message : String(e)));
|
|
226
|
+
}
|
|
227
|
+
let parsed;
|
|
228
|
+
try {
|
|
229
|
+
parsed = JSON.parse(raw);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
die("refusing to overwrite unparseable " + p + ": " +
|
|
232
|
+
(e && e.message ? e.message : String(e)) +
|
|
233
|
+
" — repair or remove the file, then retry");
|
|
234
|
+
}
|
|
235
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
236
|
+
die("refusing to overwrite " + p + ": expected a JSON object");
|
|
237
|
+
}
|
|
238
|
+
return parsed;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function writeJson(p, data) {
|
|
242
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
243
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2) + "\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function sleepSync(ms) {
|
|
247
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function processIsAlive(pid) {
|
|
251
|
+
try { process.kill(pid, 0); return true; }
|
|
252
|
+
catch (error) { return error?.code !== "ESRCH"; }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function acquireProjectWriteLock(prdPath) {
|
|
256
|
+
const lock = `${prdPath}.write-lock`;
|
|
257
|
+
const ownerFile = path.join(lock, "pid");
|
|
258
|
+
const deadline = Date.now() + 5000;
|
|
259
|
+
while (Date.now() < deadline) {
|
|
260
|
+
try {
|
|
261
|
+
fs.mkdirSync(lock);
|
|
262
|
+
try { fs.writeFileSync(ownerFile, `${process.pid}\n`, { flag: "wx" }); }
|
|
263
|
+
catch {
|
|
264
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
process.once("exit", () => releaseProjectWriteLock(lock));
|
|
268
|
+
return lock;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (error?.code !== "EEXIST") return undefined;
|
|
271
|
+
let owner = 0;
|
|
272
|
+
try { owner = Number.parseInt(fs.readFileSync(ownerFile, "utf8"), 10) || 0; } catch { /* new owner has not written its pid yet */ }
|
|
273
|
+
if (owner > 0 && !processIsAlive(owner)) {
|
|
274
|
+
let confirmedOwner = 0;
|
|
275
|
+
try { confirmedOwner = Number.parseInt(fs.readFileSync(ownerFile, "utf8"), 10) || 0; } catch { /* lock disappeared */ }
|
|
276
|
+
if (confirmedOwner === owner) {
|
|
277
|
+
const stale = `${lock}.stale-${process.pid}-${Math.random().toString(16).slice(2)}`;
|
|
278
|
+
try {
|
|
279
|
+
fs.renameSync(lock, stale);
|
|
280
|
+
fs.rmSync(stale, { recursive: true, force: true });
|
|
281
|
+
} catch { /* another waiter reclaimed it */ }
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
sleepSync(20);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function releaseProjectWriteLock(lock) {
|
|
292
|
+
try {
|
|
293
|
+
const owner = Number.parseInt(fs.readFileSync(path.join(lock, "pid"), "utf8"), 10);
|
|
294
|
+
if (owner === process.pid) fs.rmSync(lock, { recursive: true, force: true });
|
|
295
|
+
} catch { /* lock cleanup is best-effort; stale owners are reclaimed */ }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function withProjectWriteLock(prdPath, action) {
|
|
299
|
+
fs.mkdirSync(path.dirname(prdPath), { recursive: true });
|
|
300
|
+
const lock = acquireProjectWriteLock(prdPath);
|
|
301
|
+
if (!lock) die("could not acquire project write lock");
|
|
302
|
+
try { action(); }
|
|
303
|
+
finally { releaseProjectWriteLock(lock); }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const relToRoot = (p) => path.relative(HQ_ROOT, p).replace(/\\/g, "/");
|
|
307
|
+
|
|
308
|
+
function registeredCompanySlugs() {
|
|
309
|
+
let text;
|
|
310
|
+
try {
|
|
311
|
+
text = fs.readFileSync(path.join(HQ_ROOT, "companies", "manifest.yaml"), "utf8");
|
|
312
|
+
} catch (e) {
|
|
313
|
+
return new Set();
|
|
314
|
+
}
|
|
315
|
+
const slugs = new Set();
|
|
316
|
+
let wrapped = false;
|
|
317
|
+
for (const raw of text.split("\n")) {
|
|
318
|
+
if (/^companies:\s*$/.test(raw)) {
|
|
319
|
+
wrapped = true;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (wrapped && /^\S/.test(raw)) wrapped = false;
|
|
323
|
+
const m = wrapped
|
|
324
|
+
? raw.match(/^ ([a-z][a-z0-9_-]*):/)
|
|
325
|
+
: raw.match(/^([a-z][a-z0-9_-]*):/);
|
|
326
|
+
if (!m) continue;
|
|
327
|
+
const slug = m[1];
|
|
328
|
+
if (slug === "_template" || slug === "companies" || slug === "unaffiliated_repos") continue;
|
|
329
|
+
slugs.add(slug);
|
|
330
|
+
}
|
|
331
|
+
return slugs;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function projectBase(scope, company) {
|
|
335
|
+
// Never mkdir a ghost tenant. companies/<slug> on disk is not enough —
|
|
336
|
+
// the slug must be in companies/manifest.yaml. Unregistered names fall
|
|
337
|
+
// back to personal so "ok …" cannot create companies/ok.
|
|
338
|
+
if (scope === "company" && company && registeredCompanySlugs().has(company)) {
|
|
339
|
+
return path.join(HQ_ROOT, "companies", company, "projects");
|
|
340
|
+
}
|
|
341
|
+
return path.join(HQ_ROOT, "personal", "projects");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Company isolation: when a company is explicit, only reuse that company's
|
|
345
|
+
// projects. Otherwise use personal/HQ projects as the neutral home.
|
|
346
|
+
const candidateBases = (scope, company) => [projectBase(scope, company)];
|
|
347
|
+
|
|
348
|
+
function projectText(prd, prdPath) {
|
|
349
|
+
const metadata = (prd && typeof prd.metadata === "object" && prd.metadata) || {};
|
|
350
|
+
const stories = Array.isArray(prd.userStories) ? prd.userStories : [];
|
|
351
|
+
const storyText = stories.slice(0, 5)
|
|
352
|
+
.filter((s) => s && typeof s === "object")
|
|
353
|
+
.map((s) => (s.id || "") + " " + (s.title || "") + " " + (s.description || ""))
|
|
354
|
+
.join(" ");
|
|
355
|
+
return [
|
|
356
|
+
String(prd.name || ""),
|
|
357
|
+
String(prd.description || ""),
|
|
358
|
+
String(metadata.goal || ""),
|
|
359
|
+
path.basename(path.dirname(prdPath)),
|
|
360
|
+
storyText,
|
|
361
|
+
].join(" ");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function findCandidates(scope, company, query, limit = 5) {
|
|
365
|
+
const identity = ownerIdentityWords();
|
|
366
|
+
const queryWords = words(query, identity);
|
|
367
|
+
if (!queryWords.size) return [];
|
|
368
|
+
|
|
369
|
+
const scanned = [];
|
|
370
|
+
for (const base of candidateBases(scope, company)) {
|
|
371
|
+
let children;
|
|
372
|
+
try { children = fs.readdirSync(base).sort(); } catch (e) { continue; }
|
|
373
|
+
for (const name of children) {
|
|
374
|
+
const child = path.join(base, name);
|
|
375
|
+
const prdPath = path.join(child, "prd.json");
|
|
376
|
+
let stat;
|
|
377
|
+
try { stat = fs.statSync(prdPath); } catch (e) { continue; }
|
|
378
|
+
if (!stat.isFile()) continue;
|
|
379
|
+
const prd = readJson(prdPath);
|
|
380
|
+
if (!prd || typeof prd !== "object" || Array.isArray(prd)) continue;
|
|
381
|
+
// Description/goal/story prose drops MATCH_NOISE; slug tokens do not,
|
|
382
|
+
// so a project named content-workbench-platform still matches that
|
|
383
|
+
// phrase while "content review" against its description does not.
|
|
384
|
+
const hayWords = words(projectText(prd, prdPath), unionSets(identity, MATCH_NOISE));
|
|
385
|
+
const slugTokens = slugWords(name, identity);
|
|
386
|
+
scanned.push({ child, name, prd, prdPath, hayWords, slugTokens });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const df = new Map();
|
|
391
|
+
for (const item of scanned) {
|
|
392
|
+
const vocab = unionSets(item.hayWords, item.slugTokens);
|
|
393
|
+
for (const w of vocab) df.set(w, (df.get(w) || 0) + 1);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const candidates = [];
|
|
397
|
+
for (const item of scanned) {
|
|
398
|
+
const overlap = [...queryWords].filter((w) => item.hayWords.has(w) || item.slugTokens.has(w)).sort();
|
|
399
|
+
if (!overlap.length) continue;
|
|
400
|
+
// Weight by inverse document frequency across the candidate set. A word
|
|
401
|
+
// that lives in one project scores 1; a word that lives in five scores
|
|
402
|
+
// 0.2. With a single candidate this equals overlap.length, so existing
|
|
403
|
+
// distinctive-match tests keep the same integer scores.
|
|
404
|
+
let score = 0;
|
|
405
|
+
for (const w of overlap) score += 1 / (df.get(w) || 1);
|
|
406
|
+
score = Math.round(score * 1000) / 1000;
|
|
407
|
+
|
|
408
|
+
const sessions = item.prd.metadata && Array.isArray(item.prd.metadata.nativeSessions)
|
|
409
|
+
? item.prd.metadata.nativeSessions
|
|
410
|
+
: [];
|
|
411
|
+
if (sessions.length > MAX_REUSE_SESSIONS) {
|
|
412
|
+
if (slugCoverage(item.slugTokens, queryWords) < STRONG_SLUG_COVERAGE) continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
candidates.push({
|
|
416
|
+
path: relToRoot(item.prdPath),
|
|
417
|
+
projectDir: relToRoot(item.child),
|
|
418
|
+
name: item.prd.name || item.name,
|
|
419
|
+
score: score,
|
|
420
|
+
overlap: overlap.slice(0, 12),
|
|
421
|
+
slugCoverage: slugCoverage(item.slugTokens, queryWords),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
candidates.sort((a, b) => (b.score - a.score) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
426
|
+
return candidates.slice(0, limit);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function loadOrCreatePrd(projectDir, title, scope, company, prompt, origin, repoPath) {
|
|
430
|
+
const prdPath = path.join(projectDir, "prd.json");
|
|
431
|
+
// A slug collision can land us on a directory that already holds a prd.json.
|
|
432
|
+
// Reuse it if it parses; refuse loudly if it does not. Returning `{}` here
|
|
433
|
+
// used to overwrite a real PRD with a metadata-only stub.
|
|
434
|
+
const existing = readJsonForWrite(prdPath);
|
|
435
|
+
if (existing) return existing;
|
|
436
|
+
|
|
437
|
+
const slug = path.basename(projectDir);
|
|
438
|
+
const description = prompt || title;
|
|
439
|
+
return {
|
|
440
|
+
name: slug,
|
|
441
|
+
description: description,
|
|
442
|
+
branchName: "main",
|
|
443
|
+
metadata: {
|
|
444
|
+
origin: "native-session",
|
|
445
|
+
scope: scope,
|
|
446
|
+
company: company || "personal",
|
|
447
|
+
createdAt: nowIso(),
|
|
448
|
+
goal: title,
|
|
449
|
+
repoPath: repoPath,
|
|
450
|
+
status: "active",
|
|
451
|
+
executionMode: "native",
|
|
452
|
+
source: origin,
|
|
453
|
+
nativeSessions: [],
|
|
454
|
+
nativePlans: [],
|
|
455
|
+
},
|
|
456
|
+
userStories: [
|
|
457
|
+
{
|
|
458
|
+
id: "US-001",
|
|
459
|
+
title: title,
|
|
460
|
+
description: description,
|
|
461
|
+
acceptanceCriteria: [],
|
|
462
|
+
e2eTests: [],
|
|
463
|
+
priority: 1,
|
|
464
|
+
passes: false,
|
|
465
|
+
files: [],
|
|
466
|
+
labels: ["native-session"],
|
|
467
|
+
dependsOn: [],
|
|
468
|
+
notes: "Created automatically from a native Claude/Codex session. Enrich with /prd or /plan if this becomes a structured project.",
|
|
469
|
+
model_hint: "",
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function appendSession(prd, sessionId, prompt, reused) {
|
|
476
|
+
const metadata = prd.metadata || (prd.metadata = {});
|
|
477
|
+
const sessions = metadata.nativeSessions || (metadata.nativeSessions = []);
|
|
478
|
+
const entry = {
|
|
479
|
+
ts: nowIso(),
|
|
480
|
+
sessionId: sessionId || "unknown",
|
|
481
|
+
prompt: (prompt || "").slice(0, 1000),
|
|
482
|
+
reused: Boolean(reused),
|
|
483
|
+
};
|
|
484
|
+
const last = sessions[sessions.length - 1];
|
|
485
|
+
if (!sessions.length || JSON.stringify(last) !== JSON.stringify(entry)) sessions.push(entry);
|
|
486
|
+
metadata.updatedAt = entry.ts;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function writeReadme(projectDir, prd) {
|
|
490
|
+
const readme = path.join(projectDir, "README.md");
|
|
491
|
+
if (fs.existsSync(readme)) return;
|
|
492
|
+
const name = prd.name || path.basename(projectDir);
|
|
493
|
+
const description = prd.description || "";
|
|
494
|
+
fs.writeFileSync(readme,
|
|
495
|
+
"# " + name + "\n\n" +
|
|
496
|
+
description + "\n\n" +
|
|
497
|
+
"## Status\n\n" +
|
|
498
|
+
"Native session project. This folder was created automatically so work " +
|
|
499
|
+
"done outside `/plan` and `/run-project` still has a durable home.\n\n" +
|
|
500
|
+
"## Next\n\n" +
|
|
501
|
+
"- Enrich `prd.json` if this becomes structured execution work.\n" +
|
|
502
|
+
"- Keep session notes in `journal/` or `sessions/`.\n");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function setActivePointer(projectDir) {
|
|
506
|
+
const state = path.join(HQ_ROOT, ".claude", "state");
|
|
507
|
+
fs.mkdirSync(state, { recursive: true });
|
|
508
|
+
fs.writeFileSync(path.join(state, "active-session-project"), String(projectDir) + "\n");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function resolveProjectPath(raw) {
|
|
512
|
+
// This pointer is a one-line project directory, not arbitrary file content.
|
|
513
|
+
// In particular, never reinterpret merge conflict markers as directory names.
|
|
514
|
+
const match = raw.match(/^([^\r\n]+)(?:\r?\n)?$/);
|
|
515
|
+
if (!match || /<<<<<<<|=======|>>>>>>>/.test(match[1])) return null;
|
|
516
|
+
|
|
517
|
+
const projectDir = path.resolve(HQ_ROOT, match[1]);
|
|
518
|
+
const rel = path.relative(HQ_ROOT, projectDir);
|
|
519
|
+
const parts = rel.split(path.sep).filter(Boolean);
|
|
520
|
+
const isPersonalProject = parts.length >= 3 && parts[0] === "personal" && parts[1] === "projects";
|
|
521
|
+
const isCompanyProject = parts.length >= 4 && parts[0] === "companies" && parts[2] === "projects";
|
|
522
|
+
if (rel === "" || rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel) ||
|
|
523
|
+
(!isPersonalProject && !isCompanyProject)) return null;
|
|
524
|
+
|
|
525
|
+
// Project pointers can name a path that is lexically inside HQ_ROOT while
|
|
526
|
+
// a directory symlink redirects subsequent writes elsewhere. Refuse any
|
|
527
|
+
// symlink component before the path is accepted for a write operation.
|
|
528
|
+
let current = HQ_ROOT;
|
|
529
|
+
for (const part of parts) {
|
|
530
|
+
current = path.join(current, part);
|
|
531
|
+
try {
|
|
532
|
+
if (fs.lstatSync(current).isSymbolicLink()) return null;
|
|
533
|
+
} catch (error) {
|
|
534
|
+
if (error.code === "ENOENT") break;
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
const canonical = fs.realpathSync(projectDir);
|
|
540
|
+
const canonicalRel = path.relative(HQ_ROOT, canonical);
|
|
541
|
+
const canonicalParts = canonicalRel.split(path.sep).filter(Boolean);
|
|
542
|
+
const canonicalIsPersonal = canonicalParts.length >= 3 && canonicalParts[0] === "personal" && canonicalParts[1] === "projects";
|
|
543
|
+
const canonicalIsCompany = canonicalParts.length >= 4 && canonicalParts[0] === "companies" && canonicalParts[2] === "projects";
|
|
544
|
+
if (canonicalRel === "" || canonicalRel === ".." || canonicalRel.startsWith(".." + path.sep) ||
|
|
545
|
+
path.isAbsolute(canonicalRel) || (!canonicalIsPersonal && !canonicalIsCompany)) return null;
|
|
546
|
+
return canonical;
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (error.code !== "ENOENT") return null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return projectDir;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function readActivePointer(pointer) {
|
|
555
|
+
let raw;
|
|
556
|
+
try { raw = fs.readFileSync(pointer, "utf8"); } catch (e) { return null; }
|
|
557
|
+
return resolveProjectPath(raw);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function allProjectBases() {
|
|
561
|
+
const bases = [path.join(HQ_ROOT, "personal", "projects")];
|
|
562
|
+
const companiesDir = path.join(HQ_ROOT, "companies");
|
|
563
|
+
let companies;
|
|
564
|
+
try { companies = fs.readdirSync(companiesDir).sort(); } catch (e) { return bases; }
|
|
565
|
+
for (const company of companies) {
|
|
566
|
+
const base = path.join(companiesDir, company, "projects");
|
|
567
|
+
try {
|
|
568
|
+
if (fs.statSync(base).isDirectory()) bases.push(base);
|
|
569
|
+
} catch (e) {}
|
|
570
|
+
}
|
|
571
|
+
return bases;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function findProjectsBySession(sessionId) {
|
|
575
|
+
if (!sessionId) return [];
|
|
576
|
+
const matches = [];
|
|
577
|
+
for (const base of allProjectBases()) {
|
|
578
|
+
let children;
|
|
579
|
+
try { children = fs.readdirSync(base).sort(); } catch (e) { continue; }
|
|
580
|
+
for (const name of children) {
|
|
581
|
+
const projectDir = path.join(base, name);
|
|
582
|
+
const prd = readJson(path.join(projectDir, "prd.json"));
|
|
583
|
+
if (!prd || typeof prd !== "object" || Array.isArray(prd)) continue;
|
|
584
|
+
const sessions = prd.metadata && Array.isArray(prd.metadata.nativeSessions)
|
|
585
|
+
? prd.metadata.nativeSessions
|
|
586
|
+
: [];
|
|
587
|
+
if (sessions.some((session) => session && session.sessionId === sessionId)) {
|
|
588
|
+
matches.push({ projectDir: projectDir, prd: prd });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return matches;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function ensureProject(args) {
|
|
596
|
+
const query = [args.title || "", args.prompt || ""].join(" ").trim();
|
|
597
|
+
const unattended = sessionIsUnattended(args);
|
|
598
|
+
let reuse = null;
|
|
599
|
+
if (!args.force_new) {
|
|
600
|
+
const candidates = findCandidates(args.scope, args.company, query, 3);
|
|
601
|
+
const best = candidates[0];
|
|
602
|
+
const second = candidates[1];
|
|
603
|
+
if (best && best.score >= args.reuse_threshold) {
|
|
604
|
+
const ambiguous = second && (best.score - second.score) < REUSE_MARGIN;
|
|
605
|
+
const weakUnattended = unattended && (best.slugCoverage || 0) < STRONG_SLUG_COVERAGE;
|
|
606
|
+
if (!ambiguous && !weakUnattended) reuse = best;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
let projectDir, reused;
|
|
611
|
+
if (reuse) {
|
|
612
|
+
projectDir = path.join(HQ_ROOT, reuse.projectDir);
|
|
613
|
+
reused = true;
|
|
614
|
+
} else {
|
|
615
|
+
const base = projectBase(args.scope, args.company);
|
|
616
|
+
const slug = args.slug ? slugify(args.slug) : topicSlug(args.title || args.prompt);
|
|
617
|
+
projectDir = path.join(base, slug);
|
|
618
|
+
let suffix = 2;
|
|
619
|
+
while (fs.existsSync(projectDir) && (args.force_new || !fs.existsSync(path.join(projectDir, "prd.json")))) {
|
|
620
|
+
projectDir = path.join(base, slug + "-" + suffix);
|
|
621
|
+
suffix += 1;
|
|
622
|
+
}
|
|
623
|
+
reused = false;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const prdPath = path.join(projectDir, "prd.json");
|
|
627
|
+
withProjectWriteLock(prdPath, () => {
|
|
628
|
+
fs.mkdirSync(path.join(projectDir, "journal"), { recursive: true });
|
|
629
|
+
fs.mkdirSync(path.join(projectDir, "sessions"), { recursive: true });
|
|
630
|
+
|
|
631
|
+
const prd = loadOrCreatePrd(projectDir, args.title, args.scope, args.company,
|
|
632
|
+
args.prompt, args.origin, args.repo_path);
|
|
633
|
+
appendSession(prd, args.session_id, args.prompt, reused);
|
|
634
|
+
writeJson(prdPath, prd);
|
|
635
|
+
writeReadme(projectDir, prd);
|
|
636
|
+
setActivePointer(projectDir);
|
|
637
|
+
|
|
638
|
+
const stamp = nowIso().split(":").join("").split("-").join("");
|
|
639
|
+
const sessionFile = path.join(projectDir, "sessions", stamp + "-" + (args.session_id || "session") + ".json");
|
|
640
|
+
writeJson(sessionFile, {
|
|
641
|
+
ts: nowIso(),
|
|
642
|
+
kind: "native-session-start",
|
|
643
|
+
prompt: args.prompt,
|
|
644
|
+
reused: reused,
|
|
645
|
+
projectDir: relToRoot(projectDir),
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
console.log(JSON.stringify({
|
|
649
|
+
projectDir: relToRoot(projectDir),
|
|
650
|
+
prdPath: relToRoot(prdPath),
|
|
651
|
+
reused: reused,
|
|
652
|
+
match: reuse,
|
|
653
|
+
}, null, 2));
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function resolveProjectDir(project, requiredMsg) {
|
|
658
|
+
const pointer = path.join(HQ_ROOT, ".claude", "state", "active-session-project");
|
|
659
|
+
let projectDir;
|
|
660
|
+
if (project) {
|
|
661
|
+
projectDir = resolveProjectPath(project);
|
|
662
|
+
if (!projectDir) {
|
|
663
|
+
process.stderr.write("session-project: invalid project path\n");
|
|
664
|
+
process.exit(2);
|
|
665
|
+
}
|
|
666
|
+
} else if (fs.existsSync(pointer)) {
|
|
667
|
+
projectDir = readActivePointer(pointer);
|
|
668
|
+
if (!projectDir) {
|
|
669
|
+
process.stderr.write("session-project: invalid active project pointer\n");
|
|
670
|
+
process.exit(2);
|
|
671
|
+
}
|
|
672
|
+
} else if (requiredMsg) {
|
|
673
|
+
process.stderr.write(requiredMsg + "\n");
|
|
674
|
+
process.exit(1);
|
|
675
|
+
} else {
|
|
676
|
+
process.exit(0);
|
|
677
|
+
}
|
|
678
|
+
if (!path.isAbsolute(projectDir)) projectDir = path.join(HQ_ROOT, projectDir);
|
|
679
|
+
return projectDir;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function writeUniquePlanFile(plansDir, stamp, body) {
|
|
683
|
+
for (let suffix = 1; suffix <= 10000; suffix += 1) {
|
|
684
|
+
const suffixText = suffix === 1 ? "" : `-${suffix}`;
|
|
685
|
+
const candidate = path.join(plansDir, `${stamp}-native-plan${suffixText}.md`);
|
|
686
|
+
try {
|
|
687
|
+
fs.writeFileSync(candidate, body + "\n", { flag: "wx" });
|
|
688
|
+
return candidate;
|
|
689
|
+
} catch (error) {
|
|
690
|
+
if (error.code !== "EEXIST") throw error;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
throw new Error(`could not allocate a unique plan filename in ${plansDir}`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function ingestPlan(args) {
|
|
697
|
+
const projectDir = resolveProjectDir(args.project, "session-project: no active project; run ensure first");
|
|
698
|
+
const prdPath = path.join(projectDir, "prd.json");
|
|
699
|
+
withProjectWriteLock(prdPath, () => {
|
|
700
|
+
// Absent is fine (fresh project); corrupt must not be flattened into a
|
|
701
|
+
// metadata-only stub by the writeJson() below.
|
|
702
|
+
const prd = readJsonForWrite(prdPath) || {};
|
|
703
|
+
|
|
704
|
+
let body;
|
|
705
|
+
if (args.plan_file) body = fs.readFileSync(args.plan_file, "utf8");
|
|
706
|
+
else body = process.env.SESSION_PROJECT_STDIN_INPUT || "";
|
|
707
|
+
body = body.trim();
|
|
708
|
+
if (!body) process.exit(0);
|
|
709
|
+
|
|
710
|
+
const plansDir = path.join(projectDir, "sessions");
|
|
711
|
+
fs.mkdirSync(plansDir, { recursive: true });
|
|
712
|
+
const stamp = nowIso().split(":").join("").split("-").join("");
|
|
713
|
+
const planPath = writeUniquePlanFile(plansDir, stamp, body);
|
|
714
|
+
|
|
715
|
+
const metadata = prd.metadata || (prd.metadata = {});
|
|
716
|
+
const nativePlans = metadata.nativePlans || (metadata.nativePlans = []);
|
|
717
|
+
nativePlans.push({
|
|
718
|
+
ts: nowIso(),
|
|
719
|
+
path: relToRoot(planPath),
|
|
720
|
+
summary: body.slice(0, 500),
|
|
721
|
+
source: args.source,
|
|
722
|
+
});
|
|
723
|
+
metadata.updatedAt = nowIso();
|
|
724
|
+
writeJson(prdPath, prd);
|
|
725
|
+
console.log(relToRoot(planPath));
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function appendEvent(args) {
|
|
730
|
+
let projectDir = resolveProjectDir(args.project, "");
|
|
731
|
+
let prdPath = path.join(projectDir, "prd.json");
|
|
732
|
+
// Absent means the project moved and we reconcile by session id. Corrupt
|
|
733
|
+
// means the file is damaged: readJsonForWrite exits rather than letting the
|
|
734
|
+
// event land in some other project and the damage go unnoticed.
|
|
735
|
+
let lock = acquireProjectWriteLock(prdPath);
|
|
736
|
+
if (!lock) die("could not acquire project write lock");
|
|
737
|
+
try {
|
|
738
|
+
let prd = readJsonForWrite(prdPath);
|
|
739
|
+
if (!prd) {
|
|
740
|
+
const matches = findProjectsBySession(args.session_id);
|
|
741
|
+
if (matches.length !== 1) die("cannot uniquely reconcile project");
|
|
742
|
+
releaseProjectWriteLock(lock);
|
|
743
|
+
projectDir = matches[0].projectDir;
|
|
744
|
+
prdPath = path.join(projectDir, "prd.json");
|
|
745
|
+
lock = acquireProjectWriteLock(prdPath);
|
|
746
|
+
if (!lock) die("could not acquire project write lock");
|
|
747
|
+
prd = readJsonForWrite(prdPath);
|
|
748
|
+
}
|
|
749
|
+
if (!prd) die("cannot uniquely reconcile project");
|
|
750
|
+
const metadata = prd.metadata || (prd.metadata = {});
|
|
751
|
+
const events = metadata.nativeEvents || (metadata.nativeEvents = []);
|
|
752
|
+
events.push({ ts: nowIso(), kind: args.kind, summary: args.summary });
|
|
753
|
+
metadata.updatedAt = nowIso();
|
|
754
|
+
writeJson(prdPath, prd);
|
|
755
|
+
console.log(relToRoot(prdPath));
|
|
756
|
+
} finally {
|
|
757
|
+
releaseProjectWriteLock(lock);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// --- minimal argparse ---
|
|
762
|
+
function die(msg) { process.stderr.write("session-project.sh: " + msg + "\n"); process.exit(2); }
|
|
763
|
+
|
|
764
|
+
const cmd = ARGS[0];
|
|
765
|
+
const flags = {};
|
|
766
|
+
for (let i = 1; i < ARGS.length; i++) {
|
|
767
|
+
const a = ARGS[i];
|
|
768
|
+
if (a === "--force-new") { flags.force_new = true; continue; }
|
|
769
|
+
if (a === "--unattended") { flags.unattended = true; continue; }
|
|
770
|
+
if (a.startsWith("--")) {
|
|
771
|
+
flags[a.slice(2).replace(/-/g, "_")] = ARGS[i + 1] !== undefined ? ARGS[++i] : "";
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const defaults = {
|
|
777
|
+
scope: "personal", company: "", title: "", prompt: "", slug: "",
|
|
778
|
+
repo_path: "", session_id: "", origin: "native-session",
|
|
779
|
+
reuse_threshold: 3, force_new: false, unattended: false, query: "", limit: 5,
|
|
780
|
+
project: "", plan_file: "", source: "native-plan", kind: "", summary: "",
|
|
781
|
+
};
|
|
782
|
+
const args = Object.assign({}, defaults, flags);
|
|
783
|
+
args.reuse_threshold = parseInt(args.reuse_threshold, 10) || 3;
|
|
784
|
+
args.limit = parseInt(args.limit, 10) || 5;
|
|
785
|
+
|
|
786
|
+
if (cmd === "find") {
|
|
787
|
+
if (!args.query) die("find requires --query");
|
|
788
|
+
console.log(JSON.stringify(findCandidates(args.scope, args.company, args.query, args.limit), null, 2));
|
|
789
|
+
} else if (cmd === "ensure") {
|
|
790
|
+
if (!args.title) die("ensure requires --title");
|
|
791
|
+
ensureProject(args);
|
|
792
|
+
} else if (cmd === "ingest-plan") {
|
|
793
|
+
ingestPlan(args);
|
|
794
|
+
} else if (cmd === "append-event") {
|
|
795
|
+
if (!args.kind || !args.summary) die("append-event requires --kind and --summary");
|
|
796
|
+
appendEvent(args);
|
|
797
|
+
} else {
|
|
798
|
+
die("unknown or missing subcommand (find | ensure | ingest-plan | append-event)");
|
|
799
|
+
}
|
|
800
|
+
JS
|