rigjs 4.0.15 → 4.0.17
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/.claude/skills/rig-wiki/SKILL.md +28 -2
- package/RIG_WIKI_SKILL.md +28 -2
- package/built/index.js +191 -184
- package/lib/crew/index.ts +31 -0
- package/lib/crew/pending.ts +388 -0
- package/lib/crew/vault.ts +23 -0
- package/lib/wiki/ingest.ts +1 -1
- package/lib/wiki/init.ts +3 -0
- package/lib/wiki/pathGuard.ts +24 -7
- package/lib/wiki/survey.ts +10 -4
- package/lib/wiki/wikiignore.ts +93 -0
- package/package.json +3 -2
package/lib/crew/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import crewAsk from './ask';
|
|
|
8
8
|
import crewStub from './stub';
|
|
9
9
|
import { projectAdd, projectList, projectStatus, projectSync } from './project';
|
|
10
10
|
import { roleAdd, roleList, roleShow } from './roleCommand';
|
|
11
|
+
import { pendingAdd, pendingAnswer, pendingList, pendingRemove } from './pending';
|
|
11
12
|
|
|
12
13
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
14
|
export function registerCrewCommands(program: any): void {
|
|
@@ -105,6 +106,36 @@ export function registerCrewCommands(program: any): void {
|
|
|
105
106
|
.option('-c, --crew <name>', 'target crew name')
|
|
106
107
|
.action(roleShow);
|
|
107
108
|
|
|
109
|
+
const pending = crew.command('pending')
|
|
110
|
+
.description('list and manage materials the user must supply (per project)');
|
|
111
|
+
pending.command('list', { isDefault: true })
|
|
112
|
+
.description('list pending questions (default action; runs when `crew pending` is used without a subcommand)')
|
|
113
|
+
.option('--crew <name>', 'target crew name')
|
|
114
|
+
.option('-p, --project <name>', 'limit to one project')
|
|
115
|
+
.option('--all', 'include resolved questions')
|
|
116
|
+
.option('--json', 'machine-readable output')
|
|
117
|
+
.action(pendingList);
|
|
118
|
+
pending.command('add <title...>')
|
|
119
|
+
.description('record a new pending question / missing material')
|
|
120
|
+
.option('--crew <name>', 'target crew name')
|
|
121
|
+
.option('-p, --project <name>', 'project name (auto-detected from CWD if omitted)')
|
|
122
|
+
.option('--why <text>', 'why this information is needed')
|
|
123
|
+
.option('--need <text>', 'what to provide (file path, value, decision, etc.)')
|
|
124
|
+
.option('--priority <level>', 'high | medium | low')
|
|
125
|
+
.option('--asked-by <role>', 'role or person who raised the question (default: lead)')
|
|
126
|
+
.action((title: string[], opts: { crew?: string; project?: string; why?: string; need?: string; priority?: string; askedBy?: string }) => pendingAdd(title, opts));
|
|
127
|
+
pending.command('answer <id>')
|
|
128
|
+
.description('mark a pending question as resolved')
|
|
129
|
+
.option('--crew <name>', 'target crew name')
|
|
130
|
+
.option('-p, --project <name>', 'limit to one project')
|
|
131
|
+
.option('-n, --note <text>', 'short note describing what the user supplied')
|
|
132
|
+
.action(pendingAnswer);
|
|
133
|
+
pending.command('remove <id>')
|
|
134
|
+
.description('delete a pending question (use answer to keep history)')
|
|
135
|
+
.option('--crew <name>', 'target crew name')
|
|
136
|
+
.option('-p, --project <name>', 'limit to one project')
|
|
137
|
+
.action(pendingRemove);
|
|
138
|
+
|
|
108
139
|
crew.command('plan').description('planned: Lead refine + decompose').action(crewStub('plan'));
|
|
109
140
|
crew.command('refine').description('planned: update Shared/Spec.md').action(crewStub('refine'));
|
|
110
141
|
crew.command('decompose').description('planned: split Spec into owner/role tasks').action(crewStub('decompose'));
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import print from '../print';
|
|
4
|
+
import { CrewEntry, CrewProject, requireCrew, shortPath } from './config';
|
|
5
|
+
import { rootPath, writeText, readText } from './vault';
|
|
6
|
+
|
|
7
|
+
export interface PendingQuestion {
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
asked: string; // ISO date (YYYY-MM-DD)
|
|
11
|
+
askedBy?: string;
|
|
12
|
+
priority?: 'high' | 'medium' | 'low' | string;
|
|
13
|
+
why?: string;
|
|
14
|
+
need?: string;
|
|
15
|
+
notes?: string;
|
|
16
|
+
status: 'open' | 'resolved';
|
|
17
|
+
resolved?: string; // ISO date when resolved
|
|
18
|
+
answer?: string;
|
|
19
|
+
bodyExtras?: string; // extra free-form body lines we want to preserve
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PendingListOpts {
|
|
23
|
+
crew?: string;
|
|
24
|
+
project?: string;
|
|
25
|
+
all?: boolean;
|
|
26
|
+
json?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PendingAddOpts {
|
|
30
|
+
crew?: string;
|
|
31
|
+
project?: string;
|
|
32
|
+
why?: string;
|
|
33
|
+
need?: string;
|
|
34
|
+
priority?: string;
|
|
35
|
+
askedBy?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface PendingAnswerOpts {
|
|
39
|
+
crew?: string;
|
|
40
|
+
project?: string;
|
|
41
|
+
note?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PendingRemoveOpts {
|
|
45
|
+
crew?: string;
|
|
46
|
+
project?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function pendingList(opts: PendingListOpts): void {
|
|
50
|
+
const crew = requireCrew(opts.crew);
|
|
51
|
+
const targets = resolveTargets(crew, opts.project);
|
|
52
|
+
const includeResolved = !!opts.all;
|
|
53
|
+
const result: { project: string; file: string; questions: PendingQuestion[] }[] = [];
|
|
54
|
+
for (const project of targets) {
|
|
55
|
+
const file = pendingFile(crew, project.name);
|
|
56
|
+
const questions = readQuestions(file)
|
|
57
|
+
.filter(q => includeResolved || q.status === 'open');
|
|
58
|
+
result.push({ project: project.name, file, questions });
|
|
59
|
+
}
|
|
60
|
+
if (opts.json) {
|
|
61
|
+
// eslint-disable-next-line no-console
|
|
62
|
+
console.log(JSON.stringify({ ok: true, data: result }, null, 2));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const total = result.reduce((sum, r) => sum + r.questions.length, 0);
|
|
66
|
+
if (total === 0) {
|
|
67
|
+
print.info(includeResolved
|
|
68
|
+
? 'no pending questions found.'
|
|
69
|
+
: 'no open pending questions. Use --all to include resolved.');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
print.info(`pending questions${includeResolved ? ' (all)' : ' (open)'}: ${total}`);
|
|
73
|
+
for (const block of result) {
|
|
74
|
+
if (block.questions.length === 0) continue;
|
|
75
|
+
print.info(`project: ${block.project} file: ${shortPath(block.file)}`);
|
|
76
|
+
for (const q of block.questions) {
|
|
77
|
+
const tag = q.status === 'resolved' ? '[resolved]' : `[${q.priority || 'open'}]`;
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.log(`- ${q.id} ${tag} ${q.title}`);
|
|
80
|
+
if (q.why) {
|
|
81
|
+
// eslint-disable-next-line no-console
|
|
82
|
+
console.log(` why: ${q.why}`);
|
|
83
|
+
}
|
|
84
|
+
if (q.need) {
|
|
85
|
+
// eslint-disable-next-line no-console
|
|
86
|
+
console.log(` need: ${q.need}`);
|
|
87
|
+
}
|
|
88
|
+
if (q.answer) {
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.log(` answer: ${q.answer}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function pendingAdd(titleParts: string[] | undefined, opts: PendingAddOpts): void {
|
|
97
|
+
const crew = requireCrew(opts.crew);
|
|
98
|
+
const project = resolveSingleProject(crew, opts.project, 'add');
|
|
99
|
+
const title = (titleParts || []).join(' ').trim();
|
|
100
|
+
if (!title) {
|
|
101
|
+
print.error('missing question title. Usage: rig crew pending add "<title>" --project <name>');
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
const file = pendingFile(crew, project.name);
|
|
105
|
+
const questions = readQuestions(file);
|
|
106
|
+
const id = nextQuestionId(questions);
|
|
107
|
+
const today = isoDate(new Date());
|
|
108
|
+
const question: PendingQuestion = {
|
|
109
|
+
id,
|
|
110
|
+
title,
|
|
111
|
+
asked: today,
|
|
112
|
+
askedBy: opts.askedBy,
|
|
113
|
+
priority: opts.priority,
|
|
114
|
+
why: opts.why,
|
|
115
|
+
need: opts.need,
|
|
116
|
+
status: 'open',
|
|
117
|
+
};
|
|
118
|
+
questions.push(question);
|
|
119
|
+
writeQuestions(file, project, questions);
|
|
120
|
+
print.succeed(`added ${id} to ${shortPath(file)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function pendingAnswer(id: string, opts: PendingAnswerOpts): void {
|
|
124
|
+
const crew = requireCrew(opts.crew);
|
|
125
|
+
const located = findQuestion(crew, id, opts.project);
|
|
126
|
+
if (!located) {
|
|
127
|
+
print.error(`question not found: ${id}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
const { project, questions, index } = located;
|
|
131
|
+
const question = questions[index];
|
|
132
|
+
question.status = 'resolved';
|
|
133
|
+
question.resolved = isoDate(new Date());
|
|
134
|
+
if (opts.note) question.answer = opts.note;
|
|
135
|
+
writeQuestions(pendingFile(crew, project.name), project, questions);
|
|
136
|
+
print.succeed(`resolved ${id} in project ${project.name}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function pendingRemove(id: string, opts: PendingRemoveOpts): void {
|
|
140
|
+
const crew = requireCrew(opts.crew);
|
|
141
|
+
const located = findQuestion(crew, id, opts.project);
|
|
142
|
+
if (!located) {
|
|
143
|
+
print.error(`question not found: ${id}`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
const { project, questions, index } = located;
|
|
147
|
+
questions.splice(index, 1);
|
|
148
|
+
writeQuestions(pendingFile(crew, project.name), project, questions);
|
|
149
|
+
print.succeed(`removed ${id} from project ${project.name}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function pendingFile(crew: CrewEntry, project: string): string {
|
|
153
|
+
return rootPath(crew, path.join('Projects', project, 'Pending-Questions.md'));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolveTargets(crew: CrewEntry, projectName?: string): CrewProject[] {
|
|
157
|
+
if (projectName) {
|
|
158
|
+
const project = (crew.projects || []).find(p => p.name === projectName);
|
|
159
|
+
if (!project) {
|
|
160
|
+
print.error(`unknown project: ${projectName}`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
return [project];
|
|
164
|
+
}
|
|
165
|
+
return crew.projects || [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function resolveSingleProject(crew: CrewEntry, projectName: string | undefined, action: string): CrewProject {
|
|
169
|
+
const name = projectName || autoProject(crew);
|
|
170
|
+
if (!name) {
|
|
171
|
+
print.error(`cannot determine project for "${action}". Use --project <name>.`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
const project = (crew.projects || []).find(p => p.name === name);
|
|
175
|
+
if (!project) {
|
|
176
|
+
print.error(`unknown project: ${name}`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
return project;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function autoProject(crew: CrewEntry): string | undefined {
|
|
183
|
+
const cwd = process.cwd();
|
|
184
|
+
const match = (crew.projects || []).find(p => isInside(cwd, p.path));
|
|
185
|
+
return match?.name;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function findQuestion(
|
|
189
|
+
crew: CrewEntry,
|
|
190
|
+
id: string,
|
|
191
|
+
projectName?: string,
|
|
192
|
+
): { project: CrewProject; questions: PendingQuestion[]; index: number } | undefined {
|
|
193
|
+
const targets = resolveTargets(crew, projectName);
|
|
194
|
+
for (const project of targets) {
|
|
195
|
+
const file = pendingFile(crew, project.name);
|
|
196
|
+
const questions = readQuestions(file);
|
|
197
|
+
const index = questions.findIndex(q => q.id === id);
|
|
198
|
+
if (index >= 0) return { project, questions, index };
|
|
199
|
+
}
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isInside(child: string, parent: string): boolean {
|
|
204
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child));
|
|
205
|
+
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const HEADER_LINE = '<!-- rig-crew-pending:v1 -->';
|
|
209
|
+
const OPEN_HEADING = '## Open';
|
|
210
|
+
const RESOLVED_HEADING = '## Resolved';
|
|
211
|
+
|
|
212
|
+
export function readQuestions(file: string): PendingQuestion[] {
|
|
213
|
+
if (!fs.existsSync(file)) return [];
|
|
214
|
+
const text = readText(file);
|
|
215
|
+
const lines = text.split(/\r?\n/);
|
|
216
|
+
const questions: PendingQuestion[] = [];
|
|
217
|
+
let currentStatus: 'open' | 'resolved' = 'open';
|
|
218
|
+
let buffer: string[] = [];
|
|
219
|
+
let buffering = false;
|
|
220
|
+
|
|
221
|
+
const flush = () => {
|
|
222
|
+
if (!buffering) return;
|
|
223
|
+
const parsed = parseQuestionBlock(buffer, currentStatus);
|
|
224
|
+
if (parsed) questions.push(parsed);
|
|
225
|
+
buffer = [];
|
|
226
|
+
buffering = false;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
for (const line of lines) {
|
|
230
|
+
if (line.trim() === OPEN_HEADING) {
|
|
231
|
+
flush();
|
|
232
|
+
currentStatus = 'open';
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (line.trim() === RESOLVED_HEADING) {
|
|
236
|
+
flush();
|
|
237
|
+
currentStatus = 'resolved';
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (/^###\s+/.test(line)) {
|
|
241
|
+
flush();
|
|
242
|
+
buffering = true;
|
|
243
|
+
buffer.push(line);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (buffering) buffer.push(line);
|
|
247
|
+
}
|
|
248
|
+
flush();
|
|
249
|
+
return questions;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseQuestionBlock(lines: string[], status: 'open' | 'resolved'): PendingQuestion | undefined {
|
|
253
|
+
if (lines.length === 0) return undefined;
|
|
254
|
+
const heading = lines[0].replace(/^###\s+/, '').trim();
|
|
255
|
+
const m = heading.match(/^([A-Z]+-\d{6}-\d{3})\s*[—-]?\s*(.*)$/);
|
|
256
|
+
if (!m) return undefined;
|
|
257
|
+
const id = m[1];
|
|
258
|
+
let title = m[2].trim();
|
|
259
|
+
const resolvedMark = title.match(/_\(resolved\s+(\d{4}-\d{2}-\d{2})\)_\s*$/);
|
|
260
|
+
let resolved: string | undefined;
|
|
261
|
+
if (resolvedMark) {
|
|
262
|
+
resolved = resolvedMark[1];
|
|
263
|
+
title = title.slice(0, resolvedMark.index).trim();
|
|
264
|
+
}
|
|
265
|
+
const question: PendingQuestion = {
|
|
266
|
+
id,
|
|
267
|
+
title,
|
|
268
|
+
asked: '',
|
|
269
|
+
status,
|
|
270
|
+
};
|
|
271
|
+
if (resolved) {
|
|
272
|
+
question.status = 'resolved';
|
|
273
|
+
question.resolved = resolved;
|
|
274
|
+
}
|
|
275
|
+
const extras: string[] = [];
|
|
276
|
+
for (let i = 1; i < lines.length; i++) {
|
|
277
|
+
const line = lines[i];
|
|
278
|
+
const bullet = line.match(/^\s*-\s+([A-Za-z][A-Za-z ]+?):\s+(.*)$/);
|
|
279
|
+
if (bullet) {
|
|
280
|
+
const key = bullet[1].toLowerCase().replace(/\s+/g, '-');
|
|
281
|
+
const value = bullet[2].trim();
|
|
282
|
+
switch (key) {
|
|
283
|
+
case 'asked':
|
|
284
|
+
question.asked = value;
|
|
285
|
+
break;
|
|
286
|
+
case 'asked-by':
|
|
287
|
+
question.askedBy = value;
|
|
288
|
+
break;
|
|
289
|
+
case 'priority':
|
|
290
|
+
question.priority = value;
|
|
291
|
+
break;
|
|
292
|
+
case 'why-needed':
|
|
293
|
+
case 'why':
|
|
294
|
+
question.why = value;
|
|
295
|
+
break;
|
|
296
|
+
case 'what-to-provide':
|
|
297
|
+
case 'need':
|
|
298
|
+
question.need = value;
|
|
299
|
+
break;
|
|
300
|
+
case 'notes':
|
|
301
|
+
question.notes = value;
|
|
302
|
+
break;
|
|
303
|
+
case 'resolved':
|
|
304
|
+
question.resolved = value;
|
|
305
|
+
question.status = 'resolved';
|
|
306
|
+
break;
|
|
307
|
+
case 'answer':
|
|
308
|
+
question.answer = value;
|
|
309
|
+
break;
|
|
310
|
+
default:
|
|
311
|
+
extras.push(line);
|
|
312
|
+
}
|
|
313
|
+
} else if (line.trim().length > 0) {
|
|
314
|
+
extras.push(line);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (extras.length > 0) question.bodyExtras = extras.join('\n').trim();
|
|
318
|
+
return question;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function writeQuestions(file: string, project: CrewProject, questions: PendingQuestion[]): void {
|
|
322
|
+
const open = questions.filter(q => q.status === 'open');
|
|
323
|
+
const resolved = questions.filter(q => q.status === 'resolved');
|
|
324
|
+
const sections: string[] = [
|
|
325
|
+
`# ${project.name} Pending Questions`,
|
|
326
|
+
'',
|
|
327
|
+
HEADER_LINE,
|
|
328
|
+
'',
|
|
329
|
+
'Materials / facts / decisions the user must supply before the crew can proceed.',
|
|
330
|
+
'Add with `rig crew pending add "<title>" --project <name>`; resolve with `rig crew pending answer <id> --note "..."`.',
|
|
331
|
+
'',
|
|
332
|
+
OPEN_HEADING,
|
|
333
|
+
'',
|
|
334
|
+
];
|
|
335
|
+
if (open.length === 0) {
|
|
336
|
+
sections.push('_No open questions._');
|
|
337
|
+
sections.push('');
|
|
338
|
+
} else {
|
|
339
|
+
for (const q of open) sections.push(renderQuestion(q), '');
|
|
340
|
+
}
|
|
341
|
+
sections.push(RESOLVED_HEADING, '');
|
|
342
|
+
if (resolved.length === 0) {
|
|
343
|
+
sections.push('_No resolved questions yet._');
|
|
344
|
+
sections.push('');
|
|
345
|
+
} else {
|
|
346
|
+
for (const q of resolved) sections.push(renderQuestion(q), '');
|
|
347
|
+
}
|
|
348
|
+
writeText(file, sections.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function renderQuestion(q: PendingQuestion): string {
|
|
352
|
+
const headingExtra = q.status === 'resolved' && q.resolved ? ` _(resolved ${q.resolved})_` : '';
|
|
353
|
+
const lines: string[] = [`### ${q.id} — ${q.title}${headingExtra}`];
|
|
354
|
+
if (q.asked) lines.push(`- Asked: ${q.asked}`);
|
|
355
|
+
if (q.askedBy) lines.push(`- Asked by: ${q.askedBy}`);
|
|
356
|
+
if (q.priority) lines.push(`- Priority: ${q.priority}`);
|
|
357
|
+
if (q.why) lines.push(`- Why needed: ${q.why}`);
|
|
358
|
+
if (q.need) lines.push(`- What to provide: ${q.need}`);
|
|
359
|
+
if (q.notes) lines.push(`- Notes: ${q.notes}`);
|
|
360
|
+
if (q.status === 'resolved') {
|
|
361
|
+
if (q.resolved && !q.asked) lines.push(`- Resolved: ${q.resolved}`);
|
|
362
|
+
if (q.answer) lines.push(`- Answer: ${q.answer}`);
|
|
363
|
+
}
|
|
364
|
+
if (q.bodyExtras) lines.push('', q.bodyExtras);
|
|
365
|
+
return lines.join('\n');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function nextQuestionId(questions: PendingQuestion[]): string {
|
|
369
|
+
const now = new Date();
|
|
370
|
+
const yy = String(now.getFullYear()).slice(-2);
|
|
371
|
+
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
|
372
|
+
const dd = String(now.getDate()).padStart(2, '0');
|
|
373
|
+
const prefix = `Q-${yy}${mm}${dd}-`;
|
|
374
|
+
let max = 0;
|
|
375
|
+
for (const q of questions) {
|
|
376
|
+
if (!q.id.startsWith(prefix)) continue;
|
|
377
|
+
const suffix = parseInt(q.id.slice(prefix.length), 10);
|
|
378
|
+
if (!Number.isNaN(suffix) && suffix > max) max = suffix;
|
|
379
|
+
}
|
|
380
|
+
return `${prefix}${String(max + 1).padStart(3, '0')}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function isoDate(d: Date): string {
|
|
384
|
+
const yyyy = d.getFullYear();
|
|
385
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
386
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
387
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
388
|
+
}
|
package/lib/crew/vault.ts
CHANGED
|
@@ -47,6 +47,7 @@ export function ensureProject(crew: CrewEntry, project: CrewProject): void {
|
|
|
47
47
|
writeProjectOwnerFile(path.join(base, 'Owner.md'), project);
|
|
48
48
|
writeIfMissing(path.join(base, 'Context.md'), `# ${project.name} Context\n\n`);
|
|
49
49
|
writeIfMissing(path.join(base, 'Tasks.md'), renderProjectTasksFile(project));
|
|
50
|
+
writeIfMissing(path.join(base, 'Pending-Questions.md'), renderProjectPendingFile(project));
|
|
50
51
|
ensureTasklists(base);
|
|
51
52
|
writeIfMissing(path.join(base, 'Decisions.md'), `# ${project.name} Decisions\n\n`);
|
|
52
53
|
writeIfMissing(path.join(base, 'Test-Plan.md'), `# ${project.name} Test Plan\n\n`);
|
|
@@ -185,6 +186,26 @@ function renderProjectTasksFile(project: CrewProject): string {
|
|
|
185
186
|
].join('\n');
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
function renderProjectPendingFile(project: CrewProject): string {
|
|
190
|
+
return [
|
|
191
|
+
`# ${project.name} Pending Questions`,
|
|
192
|
+
'',
|
|
193
|
+
'<!-- rig-crew-pending:v1 -->',
|
|
194
|
+
'',
|
|
195
|
+
'Materials / facts / decisions the user must supply before the crew can proceed.',
|
|
196
|
+
'Add with `rig crew pending add "<title>" --project <name>`; resolve with `rig crew pending answer <id> --note "..."`.',
|
|
197
|
+
'',
|
|
198
|
+
'## Open',
|
|
199
|
+
'',
|
|
200
|
+
'_No open questions._',
|
|
201
|
+
'',
|
|
202
|
+
'## Resolved',
|
|
203
|
+
'',
|
|
204
|
+
'_No resolved questions yet._',
|
|
205
|
+
'',
|
|
206
|
+
].join('\n');
|
|
207
|
+
}
|
|
208
|
+
|
|
188
209
|
function renderProjectAgentTasksFile(project: CrewProject, role: CrewRoleDefinition): string {
|
|
189
210
|
return [
|
|
190
211
|
`# ${project.name} ${role.title} Tasks`,
|
|
@@ -254,6 +275,7 @@ function renderVaultAgentInstructions(crew: CrewEntry): string {
|
|
|
254
275
|
`- Role registry: \`${root}/Shared/Roles.md\``,
|
|
255
276
|
`- Reusable role descriptions: \`${root}/<role>/Role.md\` and \`${root}/Roles/<custom-role>/Role.md\``,
|
|
256
277
|
`- Project owner memory: \`${root}/Projects/<project>/\``,
|
|
278
|
+
`- Per-project pending questions (待补充资料): \`${root}/Projects/<project>/Pending-Questions.md\` — managed by \`rig crew pending\``,
|
|
257
279
|
`- Project-scoped agent tasks: \`${root}/Projects/<project>/Agents/<role>/Tasks.md\``,
|
|
258
280
|
`- Large active task batches: \`${root}/Projects/<project>/Tasklists/active/*.md\` and \`${root}/Projects/<project>/Agents/<role>/Tasklists/active/*.md\`. Keep \`Tasks.md\` short; archived tasklists are not part of the active dashboard.`,
|
|
259
281
|
'- Vault-local scratch projects belong under `tmp/<project>/`.',
|
|
@@ -272,6 +294,7 @@ function renderVaultAgentInstructions(crew: CrewEntry): string {
|
|
|
272
294
|
'5. Treat Crew Lead as the default orchestrator prompt/protocol, not as a required Claude/Codex subagent. Subagents may be used as optional executors for specific roles, but Vault files are the source of truth.',
|
|
273
295
|
'6. Lead communicates with other roles through Markdown tasks and delegation packets, not private chat state. Use `[role:: <role>]`, `[owner:: <owner>]`, `[project:: <project>]`, `[executor:: <executor>]`, and status fields in the relevant project-scoped `Tasks.md`.',
|
|
274
296
|
`7. Worker results must be written back to the relevant role/project files under \`${root}/\`; user-facing questions go to \`${root}/Inbox.md\` for Lead to surface.`,
|
|
297
|
+
`8. Whenever a project is blocked by missing user-supplied material (API key, screenshot, vendor decision, sample data), record it with \`rig crew pending add ...\` rather than silently asking the human; when the user supplies it, the agent itself calls \`rig crew pending answer <id> --note "<summary>"\` and continues. Check \`rig crew pending --project <name>\` before starting any new tick on that project.`,
|
|
275
298
|
'',
|
|
276
299
|
AGENT_RULES_END,
|
|
277
300
|
].join('\n');
|
package/lib/wiki/ingest.ts
CHANGED
|
@@ -40,7 +40,7 @@ export default async function wikiIngest(source: string, opts: IngestOpts): Prom
|
|
|
40
40
|
print.error(`source not found: ${source}`);
|
|
41
41
|
process.exit(1);
|
|
42
42
|
}
|
|
43
|
-
const guard = guardPath(absSource, target.root || target.path);
|
|
43
|
+
const guard = guardPath(absSource, target.root || target.path, target.root);
|
|
44
44
|
if (!guard.ok) {
|
|
45
45
|
print.error('refusing to ingest from a hidden or gitignored path.');
|
|
46
46
|
// eslint-disable-next-line no-console
|
package/lib/wiki/init.ts
CHANGED
|
@@ -229,6 +229,9 @@ export default function wikiInit(scope?: string): void {
|
|
|
229
229
|
print.succeed(`vault initialized at ${shortPath(vaultDir)} (${scopeLabel})`);
|
|
230
230
|
print.info(`next: edit ${shortPath(path.join(vaultDir, 'purpose.md'))} to describe what this wiki is for.`);
|
|
231
231
|
print.info(`then run \`rig wiki sync\` from anywhere inside ${shortPath(cwd)} to ingest, update, and prune in one shot.`);
|
|
232
|
+
if (!fs.existsSync(path.join(scopeAbs, '.wikiignore'))) {
|
|
233
|
+
print.info(`tip: drop a \`.wikiignore\` (gitignore syntax) at ${shortPath(scopeAbs)} for paths the wiki should skip even though git tracks them (e.g. \`keychain/\`, \`secrets/\`).`);
|
|
234
|
+
}
|
|
232
235
|
}
|
|
233
236
|
|
|
234
237
|
function writeIfMissing(file: string, content: string) {
|
package/lib/wiki/pathGuard.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
// Shared guards for rig wiki — refuse to operate on hidden paths
|
|
2
|
-
// .gitignored content. The user must explicitly
|
|
3
|
-
//
|
|
1
|
+
// Shared guards for rig wiki — refuse to operate on hidden paths,
|
|
2
|
+
// .gitignored content, or .wikiignored content. The user must explicitly
|
|
3
|
+
// copy such files into a visible, tracked, non-wikiignored location
|
|
4
|
+
// before they can become wiki sources.
|
|
4
5
|
|
|
5
6
|
import path from 'path';
|
|
6
7
|
import { spawnSync } from 'child_process';
|
|
8
|
+
import { batchWikiIgnored } from './wikiignore';
|
|
7
9
|
|
|
8
10
|
export interface PathGuardResult {
|
|
9
11
|
ok: boolean;
|
|
10
|
-
reason?: 'hidden' | 'gitignored';
|
|
12
|
+
reason?: 'hidden' | 'gitignored' | 'wikiignored';
|
|
11
13
|
segment?: string; // for hidden: the offending segment
|
|
12
14
|
detail?: string; // human-readable detail
|
|
13
15
|
}
|
|
@@ -49,9 +51,14 @@ export function isGitignored(p: string, repoCwd: string): boolean | null {
|
|
|
49
51
|
|
|
50
52
|
/**
|
|
51
53
|
* Validate a path as a wiki source / target. Returns ok if visible AND
|
|
52
|
-
* not gitignored. Use for `init` target and `ingest`
|
|
54
|
+
* not gitignored AND not wikiignored. Use for `init` target and `ingest`
|
|
55
|
+
* source.
|
|
56
|
+
*
|
|
57
|
+
* `vaultRoot` (optional) enables the `.wikiignore` check; pass the
|
|
58
|
+
* project root so layered `.wikiignore` files are consulted. Omit for
|
|
59
|
+
* pre-vault callers (e.g. `init` deciding where the vault dir lives).
|
|
53
60
|
*/
|
|
54
|
-
export function guardPath(absPath: string, repoCwd: string): PathGuardResult {
|
|
61
|
+
export function guardPath(absPath: string, repoCwd: string, vaultRoot?: string): PathGuardResult {
|
|
55
62
|
const seg = hiddenSegment(absPath);
|
|
56
63
|
if (seg) {
|
|
57
64
|
return {
|
|
@@ -69,6 +76,16 @@ export function guardPath(absPath: string, repoCwd: string): PathGuardResult {
|
|
|
69
76
|
detail: '.gitignore matches this path',
|
|
70
77
|
};
|
|
71
78
|
}
|
|
79
|
+
if (vaultRoot) {
|
|
80
|
+
const wk = batchWikiIgnored([absPath], vaultRoot);
|
|
81
|
+
if (wk.has(absPath)) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: 'wikiignored',
|
|
85
|
+
detail: '.wikiignore matches this path',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
72
89
|
return { ok: true };
|
|
73
90
|
}
|
|
74
91
|
|
|
@@ -77,7 +94,7 @@ export function refusalMessage(target: string, r: PathGuardResult): string {
|
|
|
77
94
|
`refused: ${target}`,
|
|
78
95
|
` reason: ${r.reason} — ${r.detail}`,
|
|
79
96
|
'',
|
|
80
|
-
' rig wiki refuses to operate on hidden files/dirs or .
|
|
97
|
+
' rig wiki refuses to operate on hidden files/dirs, .gitignored, or .wikiignored content.',
|
|
81
98
|
' If you really need this content, copy it to a visible, tracked location first:',
|
|
82
99
|
'',
|
|
83
100
|
' cp -R <hidden-or-ignored> <wiki>/raw/manual-copy/ # then `rig wiki ingest`',
|
package/lib/wiki/survey.ts
CHANGED
|
@@ -18,6 +18,7 @@ import print from '../print';
|
|
|
18
18
|
import { requireVault, loadRigConfig, WikiEntry } from './config';
|
|
19
19
|
import { isBinaryExtension } from './fileTypes';
|
|
20
20
|
import { batchGitignored } from './gitignore';
|
|
21
|
+
import { batchWikiIgnored } from './wikiignore';
|
|
21
22
|
import { adapters } from './agent/registry';
|
|
22
23
|
import { default as wikiIngest } from './ingest';
|
|
23
24
|
|
|
@@ -105,10 +106,15 @@ function collectCandidates(entry: WikiEntry): Candidate[] {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
// Two-layer ignore filter:
|
|
110
|
+
// 1. .gitignore — multi-repo-aware via `git check-ignore --stdin`
|
|
111
|
+
// 2. .wikiignore — same syntax, wiki-only (lets us hide paths git
|
|
112
|
+
// tracks intentionally, e.g. overmind's `keychain/`)
|
|
113
|
+
// A candidate is dropped if either layer matches.
|
|
114
|
+
const absList = out.map(c => c.abs);
|
|
115
|
+
const giIgnored = batchGitignored(absList);
|
|
116
|
+
const wkIgnored = batchWikiIgnored(absList, entry.root);
|
|
117
|
+
return out.filter(c => !giIgnored.has(c.abs) && !wkIgnored.has(c.abs));
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
async function classifyWithAgent(target: WikiEntry, candidates: Candidate[]): Promise<SurveyRow[]> {
|