instar 0.28.86 → 0.28.87

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 (33) hide show
  1. package/dist/core/InitiativeTracker.d.ts +1 -0
  2. package/dist/core/InitiativeTracker.d.ts.map +1 -1
  3. package/dist/core/InitiativeTracker.js +8 -1
  4. package/dist/core/InitiativeTracker.js.map +1 -1
  5. package/dist/core/PlanDocParser.d.ts +62 -0
  6. package/dist/core/PlanDocParser.d.ts.map +1 -0
  7. package/dist/core/PlanDocParser.js +328 -0
  8. package/dist/core/PlanDocParser.js.map +1 -0
  9. package/dist/core/SafeYaml.d.ts +45 -0
  10. package/dist/core/SafeYaml.d.ts.map +1 -0
  11. package/dist/core/SafeYaml.js +230 -0
  12. package/dist/core/SafeYaml.js.map +1 -0
  13. package/dist/core/SessionManager.d.ts +12 -0
  14. package/dist/core/SessionManager.d.ts.map +1 -1
  15. package/dist/core/SessionManager.js +60 -0
  16. package/dist/core/SessionManager.js.map +1 -1
  17. package/dist/core/StageTransitionValidator.d.ts +97 -0
  18. package/dist/core/StageTransitionValidator.d.ts.map +1 -0
  19. package/dist/core/StageTransitionValidator.js +338 -0
  20. package/dist/core/StageTransitionValidator.js.map +1 -0
  21. package/dist/monitoring/StallTriageNurse.d.ts +11 -0
  22. package/dist/monitoring/StallTriageNurse.d.ts.map +1 -1
  23. package/dist/monitoring/StallTriageNurse.js +66 -0
  24. package/dist/monitoring/StallTriageNurse.js.map +1 -1
  25. package/dist/server/routes.d.ts.map +1 -1
  26. package/dist/server/routes.js +312 -0
  27. package/dist/server/routes.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/data/builtin-manifest.json +47 -47
  30. package/upgrades/0.28.87.md +153 -0
  31. package/upgrades/side-effects/0.28.86.md +13 -0
  32. package/upgrades/side-effects/project-scope-phase1a-pr2.md +377 -0
  33. package/upgrades/side-effects/verify-injection-stuck-input.md +107 -0
@@ -368,6 +368,132 @@ export function createDeliveryFailedHandler(opts) {
368
368
  const SESSION_NAME_RE = /^[a-zA-Z0-9_-]{1,200}$/;
369
369
  const JOB_SLUG_RE = /^[a-zA-Z0-9_-]{1,100}$/;
370
370
  const VALID_SORTS = ['significance', 'recent', 'name'];
371
+ // ── Project-scope helpers (Phase 1a PR 2) ─────────────────────────
372
+ /**
373
+ * Hash the incoming Authorization header to derive a per-token identity
374
+ * for the projects-rate counter. We never store the raw token; the hash
375
+ * is a stable per-token key.
376
+ */
377
+ function hashAuthHeader(header) {
378
+ if (typeof header !== 'string')
379
+ return 'anon';
380
+ return createHash('sha256').update(header).digest('hex').slice(0, 16);
381
+ }
382
+ /**
383
+ * Read-check-increment for the per-token projects-creation counter.
384
+ * Returns `{ok: true}` if the request is within the 5/hour window, or
385
+ * `{ok: false, windowEnds}` if the limit is reached.
386
+ *
387
+ * The counter file lives under `.instar/local/projects-rate.json` — the
388
+ * `.instar/local/` directory is gitignored (Phase 1.12) so the counter
389
+ * never syncs across machines. We bound file size by retaining only
390
+ * tokens whose window is still open.
391
+ */
392
+ function checkAndIncrementProjectsRate(stateDir, tokenHash) {
393
+ const localDir = path.join(stateDir, 'local');
394
+ try {
395
+ fs.mkdirSync(localDir, { recursive: true });
396
+ }
397
+ catch {
398
+ // mkdir failures fall through; we'll fail the write below.
399
+ }
400
+ const ratePath = path.join(localDir, 'projects-rate.json');
401
+ let table = {};
402
+ try {
403
+ if (fs.existsSync(ratePath)) {
404
+ const raw = JSON.parse(fs.readFileSync(ratePath, 'utf-8'));
405
+ if (raw && typeof raw === 'object')
406
+ table = raw;
407
+ }
408
+ }
409
+ catch {
410
+ table = {};
411
+ }
412
+ const now = Date.now();
413
+ const WINDOW_MS = 60 * 60 * 1000;
414
+ // GC entries whose window has expired.
415
+ for (const k of Object.keys(table)) {
416
+ if (now - (table[k]?.windowStart ?? 0) >= WINDOW_MS)
417
+ delete table[k];
418
+ }
419
+ const entry = table[tokenHash];
420
+ if (!entry || now - entry.windowStart >= WINDOW_MS) {
421
+ table[tokenHash] = { count: 1, windowStart: now };
422
+ }
423
+ else if (entry.count >= 5) {
424
+ return {
425
+ ok: false,
426
+ windowEnds: new Date(entry.windowStart + WINDOW_MS).toISOString(),
427
+ };
428
+ }
429
+ else {
430
+ entry.count += 1;
431
+ }
432
+ try {
433
+ fs.writeFileSync(ratePath, JSON.stringify(table, null, 2), 'utf-8');
434
+ }
435
+ catch {
436
+ // If we can't persist, fail open: still allow the call (rate-limit
437
+ // is best-effort, not a security boundary).
438
+ }
439
+ return { ok: true };
440
+ }
441
+ /** Project + children creation as a single logical operation. */
442
+ async function createProjectAndChildren(tracker, parsed) {
443
+ if (!parsed.project)
444
+ throw new Error('parsed.project is null');
445
+ const proj = parsed.project;
446
+ // Group children by roundName, preserving first-seen order.
447
+ const roundOrder = [];
448
+ const byRound = new Map();
449
+ for (const c of parsed.children) {
450
+ if (!byRound.has(c.roundName)) {
451
+ roundOrder.push(c.roundName);
452
+ byRound.set(c.roundName, []);
453
+ }
454
+ byRound.get(c.roundName).push(c.id);
455
+ }
456
+ const rounds = roundOrder.map((name) => ({
457
+ name,
458
+ itemIds: byRound.get(name),
459
+ status: 'pending',
460
+ }));
461
+ const projectInit = await tracker.create({
462
+ id: proj.id,
463
+ title: proj.title,
464
+ description: proj.description,
465
+ phases: [{ id: 'overview', name: 'overview', status: 'pending' }],
466
+ kind: 'project',
467
+ rounds,
468
+ sourceDocs: proj.sourceDocs,
469
+ autoAdvance: proj.autoAdvance,
470
+ telegramTopicId: proj.telegramTopicId,
471
+ targetRepoPath: proj.targetRepoPath,
472
+ });
473
+ const createdChildren = [];
474
+ for (const child of parsed.children) {
475
+ const c = await tracker.create({
476
+ id: child.id,
477
+ title: child.title,
478
+ description: `Source: ${child.sourceTag}; effort: ${child.effortTag}`,
479
+ phases: [{ id: 'outline', name: 'outline', status: 'pending' }],
480
+ kind: 'task',
481
+ pipelineStage: child.pipelineStage,
482
+ parentProjectId: proj.id,
483
+ });
484
+ createdChildren.push(c);
485
+ }
486
+ return { project: projectInit, children: createdChildren };
487
+ }
488
+ /** Subset a record by an allowlist of fields. */
489
+ function pickFields(obj, fields) {
490
+ const out = {};
491
+ for (const k of fields) {
492
+ if (k in obj)
493
+ out[k] = obj[k];
494
+ }
495
+ return out;
496
+ }
371
497
  export function createRoutes(ctx) {
372
498
  const router = Router();
373
499
  // ── PR-REVIEW-HARDENING kill-switch (Phase A) ─────────────────────
@@ -4919,6 +5045,192 @@ export function createRoutes(ctx) {
4919
5045
  }
4920
5046
  res.json({ id: req.params.id, deleted: true });
4921
5047
  });
5048
+ // ── Projects (project-scope, Phase 1a PR 2) ─────────────────────
5049
+ //
5050
+ // Subset of Phase 1.3 endpoints from PROJECT-SCOPE-SPEC.md. Full
5051
+ // advance/halt/ack endpoints ship in PR 3 (skill) + Phase 1b (runner).
5052
+ //
5053
+ // Auth is enforced globally by `authMiddleware` on the AgentServer; we
5054
+ // don't re-check the bearer here. Rate limiting on POST /projects is
5055
+ // enforced via a small per-token counter in `.instar/local/projects-rate.json`.
5056
+ router.get('/projects', (_req, res) => {
5057
+ if (!ctx.initiativeTracker) {
5058
+ res.status(503).json({ error: 'Initiative tracker not configured' });
5059
+ return;
5060
+ }
5061
+ const items = ctx.initiativeTracker.list({ kind: 'project' });
5062
+ res.json({ items, count: items.length });
5063
+ });
5064
+ router.get('/projects/:id', (req, res) => {
5065
+ if (!ctx.initiativeTracker) {
5066
+ res.status(503).json({ error: 'Initiative tracker not configured' });
5067
+ return;
5068
+ }
5069
+ if (!initiativeIdRe.test(req.params.id)) {
5070
+ res.status(400).json({ error: 'invalid project id' });
5071
+ return;
5072
+ }
5073
+ const project = ctx.initiativeTracker.get(req.params.id);
5074
+ if (!project || (project.kind ?? 'task') !== 'project') {
5075
+ res.status(404).json({ error: 'project not found' });
5076
+ return;
5077
+ }
5078
+ // Join children that point at this project via parentProjectId.
5079
+ const children = ctx.initiativeTracker
5080
+ .list()
5081
+ .filter((i) => i.parentProjectId === project.id);
5082
+ // Optional field selector: `?fields=id,title,pipelineStage`. Used by
5083
+ // the dashboard projects selector (Phase 1.10). Always preserves `id`.
5084
+ const fieldsParam = typeof req.query.fields === 'string' ? req.query.fields : '';
5085
+ if (fieldsParam.trim()) {
5086
+ const fields = new Set(fieldsParam
5087
+ .split(',')
5088
+ .map((s) => s.trim())
5089
+ .filter(Boolean));
5090
+ fields.add('id');
5091
+ const pickProj = pickFields(project, fields);
5092
+ const pickKids = children.map((c) => pickFields(c, fields));
5093
+ res.json({ project: pickProj, children: pickKids });
5094
+ return;
5095
+ }
5096
+ res.json({ project, children });
5097
+ });
5098
+ router.get('/projects/:id/next', (req, res) => {
5099
+ if (!initiativeIdRe.test(req.params.id)) {
5100
+ res.status(400).json({ error: 'invalid project id' });
5101
+ return;
5102
+ }
5103
+ // Next-action computation lands in Phase 1b's ProjectRoundRunner.
5104
+ res.status(501).json({
5105
+ action: 'not-implemented',
5106
+ message: 'next-action computation lands in Phase 1b',
5107
+ });
5108
+ });
5109
+ router.post('/projects', async (req, res) => {
5110
+ if (!ctx.initiativeTracker) {
5111
+ res.status(503).json({ error: 'Initiative tracker not configured' });
5112
+ return;
5113
+ }
5114
+ const planDocPath = (req.body ?? {}).planDocPath;
5115
+ if (typeof planDocPath !== 'string' || !planDocPath.trim()) {
5116
+ res.status(400).json({ error: '"planDocPath" required (absolute path)' });
5117
+ return;
5118
+ }
5119
+ // ── Rate limit: 5 creates/hour per auth token ──────────────────
5120
+ // Per-token counter persisted under `.instar/local/projects-rate.json`.
5121
+ // `.instar/local/` is gitignored (Phase 1.12) so the counter never
5122
+ // syncs across machines — matches the spec's per-agent semantics.
5123
+ const tokenHash = hashAuthHeader(req.headers.authorization);
5124
+ const rateCheck = checkAndIncrementProjectsRate(ctx.config.stateDir, tokenHash);
5125
+ if (!rateCheck.ok) {
5126
+ res.status(429).json({
5127
+ error: 'rate limit exceeded',
5128
+ windowEnds: rateCheck.windowEnds,
5129
+ limit: 5,
5130
+ });
5131
+ return;
5132
+ }
5133
+ const { parsePlanDoc } = await import('../core/PlanDocParser.js');
5134
+ const parsed = await parsePlanDoc(planDocPath);
5135
+ if (!parsed.project || parsed.errors.length > 0) {
5136
+ res.status(400).json({
5137
+ error: 'plan doc validation failed',
5138
+ errors: parsed.errors,
5139
+ });
5140
+ return;
5141
+ }
5142
+ // Existing slug? Reject unless `unarchive: true` (Phase 1.6).
5143
+ const existing = ctx.initiativeTracker.get(parsed.project.id);
5144
+ if (existing) {
5145
+ if (existing.status !== 'archived' || parsed.project.unarchive !== true) {
5146
+ res.status(409).json({
5147
+ error: `project "${parsed.project.id}" already exists`,
5148
+ archived: existing.status === 'archived',
5149
+ });
5150
+ return;
5151
+ }
5152
+ // Un-archive path is implemented in Phase 1.6 via re-parse rules.
5153
+ // Phase 1a defers full re-parse mutation table to PR 3+; for now we
5154
+ // reject with a clear message so users don't accidentally clobber.
5155
+ res.status(409).json({
5156
+ error: 'unarchive flow not yet implemented; archive then re-create with a new id',
5157
+ });
5158
+ return;
5159
+ }
5160
+ try {
5161
+ const created = await createProjectAndChildren(ctx.initiativeTracker, parsed);
5162
+ res.status(201).json(created);
5163
+ }
5164
+ catch (err) {
5165
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
5166
+ }
5167
+ });
5168
+ router.post('/projects/validate', async (req, res) => {
5169
+ const planDocPath = (req.body ?? {}).planDocPath;
5170
+ if (typeof planDocPath !== 'string' || !planDocPath.trim()) {
5171
+ res.status(400).json({ error: '"planDocPath" required (absolute path)' });
5172
+ return;
5173
+ }
5174
+ const { parsePlanDoc } = await import('../core/PlanDocParser.js');
5175
+ const parsed = await parsePlanDoc(planDocPath);
5176
+ res.json({
5177
+ ok: parsed.errors.length === 0 && !!parsed.project,
5178
+ project: parsed.project,
5179
+ children: parsed.children,
5180
+ errors: parsed.errors,
5181
+ });
5182
+ });
5183
+ router.delete('/projects/:id', async (req, res) => {
5184
+ if (!ctx.initiativeTracker) {
5185
+ res.status(503).json({ error: 'Initiative tracker not configured' });
5186
+ return;
5187
+ }
5188
+ if (!initiativeIdRe.test(req.params.id)) {
5189
+ res.status(400).json({ error: 'invalid project id' });
5190
+ return;
5191
+ }
5192
+ const project = ctx.initiativeTracker.get(req.params.id);
5193
+ if (!project || (project.kind ?? 'task') !== 'project') {
5194
+ res.status(404).json({ error: 'project not found' });
5195
+ return;
5196
+ }
5197
+ // If-Match required for archive (P4: OCC on mutating endpoints).
5198
+ const ifMatchHeader = req.headers['if-match'];
5199
+ if (typeof ifMatchHeader !== 'string' || !ifMatchHeader.trim()) {
5200
+ res.status(428).json({ error: 'If-Match header required for archive' });
5201
+ return;
5202
+ }
5203
+ const ifMatch = parseInt(ifMatchHeader.replace(/"/g, ''), 10);
5204
+ if (!Number.isInteger(ifMatch) || ifMatch <= 0) {
5205
+ res.status(400).json({ error: 'If-Match must be a positive integer version' });
5206
+ return;
5207
+ }
5208
+ // Refuse archive if any round is in-progress.
5209
+ const activeRound = (project.rounds ?? []).find((r) => r.status === 'in-progress');
5210
+ if (activeRound) {
5211
+ res.status(409).json({
5212
+ error: 'cannot archive while a round is in-progress',
5213
+ activeRound: activeRound.name,
5214
+ });
5215
+ return;
5216
+ }
5217
+ try {
5218
+ const updated = await ctx.initiativeTracker.update(project.id, {
5219
+ status: 'archived',
5220
+ ifMatch,
5221
+ });
5222
+ res.json({ id: updated.id, status: updated.status, version: updated.version });
5223
+ }
5224
+ catch (err) {
5225
+ const msg = err instanceof Error ? err.message : String(err);
5226
+ if (err && err.name === 'OccVersionMismatchError') {
5227
+ const currentVersion = err.currentVersion;
5228
+ res.status(409).json({ error: 'version mismatch', currentVersion });
5229
+ return;
5230
+ }
5231
+ res.status(400).json({ error: msg });
5232
+ }
5233
+ });
4922
5234
  // ── WhatsApp ────────────────────────────────────────────────────
4923
5235
  router.get('/whatsapp/status', (_req, res) => {
4924
5236
  if (!ctx.whatsapp) {