arkgate 4.6.5 → 4.6.7

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 (51) hide show
  1. package/CHANGELOG.md +72 -2106
  2. package/README.md +11 -9
  3. package/bin/ark-check-runtime.mjs +36 -332
  4. package/bin/ark-mcp-runtime.mjs +7 -323
  5. package/bin/ark-shared.mjs +24 -158
  6. package/bin/ark.mjs +13 -3
  7. package/bin/lib/adoption-stance.mjs +104 -0
  8. package/bin/lib/check-args.mjs +173 -0
  9. package/bin/lib/check-config-detect.mjs +101 -0
  10. package/bin/lib/check-watch.mjs +80 -0
  11. package/bin/lib/ci-merge-boundary.mjs +4 -2
  12. package/bin/lib/deep-module-coach.mjs +3 -0
  13. package/bin/lib/design-delta.mjs +2 -2
  14. package/bin/lib/design-smells.mjs +1 -1
  15. package/bin/lib/diagnostic-catalog.mjs +1 -1
  16. package/bin/lib/doctor-advisories.mjs +2 -2
  17. package/bin/lib/doctor-human.mjs +509 -0
  18. package/bin/lib/doctor-next-actions.mjs +20 -2
  19. package/bin/lib/doctor-plan.mjs +86 -456
  20. package/bin/lib/enforcement-honesty.mjs +70 -0
  21. package/bin/lib/first-run-help.mjs +8 -7
  22. package/bin/lib/github-enforcement.mjs +22 -9
  23. package/bin/lib/html-report-advisories.mjs +10 -2
  24. package/bin/lib/html-report.mjs +26 -9
  25. package/bin/lib/mcp-adoption.mjs +19 -0
  26. package/bin/lib/mcp-hook-payload.mjs +328 -0
  27. package/bin/lib/package-manager.mjs +174 -0
  28. package/bin/lib/policy-delta-io.mjs +5 -1
  29. package/bin/lib/post-green-path.mjs +5 -1
  30. package/bin/lib/product-copy.mjs +6 -3
  31. package/bin/lib/start-preview.mjs +12 -22
  32. package/bin/lib/status-command.mjs +16 -0
  33. package/bin/lib/status-manifest.mjs +8 -2
  34. package/bin/lib/team-parliament-io.mjs +66 -2
  35. package/bin/lib/team-parliament.mjs +25 -5
  36. package/bin/lib/unavailable-analysis.mjs +1 -0
  37. package/dist/index.cjs +2 -2
  38. package/dist/index.d.ts +10 -2
  39. package/dist/index.js +2 -2
  40. package/docs/README.md +6 -10
  41. package/docs/ai-gates.md +12 -5
  42. package/docs/configuration.md +9 -1
  43. package/docs/diagnostics.md +2 -2
  44. package/docs/package-surface.md +6 -4
  45. package/docs/product-voice.md +6 -4
  46. package/docs/threat-model.md +2 -2
  47. package/docs/use.md +5 -4
  48. package/package.json +1 -1
  49. package/schemas/ark.design-delta.schema.json +1 -1
  50. package/server.json +2 -2
  51. package/templates/agent-skills/README.md +1 -1
@@ -34,10 +34,14 @@ export {
34
34
  suggestStewards,
35
35
  };
36
36
 
37
+ /** Kill hung git instead of stalling CI. */
38
+ export const SPAWN_TIMEOUT_MS = 8000;
39
+
37
40
  function runGit(cwd, args) {
38
41
  return spawnSync('git', ['-C', cwd, ...args], {
39
42
  encoding: 'utf8',
40
43
  stdio: ['ignore', 'pipe', 'pipe'],
44
+ timeout: SPAWN_TIMEOUT_MS,
41
45
  });
42
46
  }
43
47
 
@@ -203,11 +207,30 @@ export function teamCheckRequested(args, config) {
203
207
  args.against ||
204
208
  args.persona ||
205
209
  args.contractSession ||
210
+ args.updateBaseline ||
206
211
  (args.strictMerge && teamStewardsFromConfig(config).length > 0)
207
212
  );
208
213
  }
209
214
 
210
215
  export function runTeamPreflight({ root, args, config, policyDelta, teamBase }) {
216
+ const weakening =
217
+ policyDelta?.classification === 'weakening' ||
218
+ policyDelta?.classification === 'judgment-required';
219
+ if (weakening && !contractSessionFrom(args)) {
220
+ const message =
221
+ 'Weakening the contract requires --contract-session (and --policy-ack bound to both hashes).';
222
+ const teamParliament = {
223
+ deny: true,
224
+ reasonId: 'steward-only-loosen',
225
+ message,
226
+ kinds: ['loosen'],
227
+ };
228
+ return {
229
+ halt: { exitCode: 1, message, teamParliament, fail: true },
230
+ teamParliament,
231
+ changedPaths: [],
232
+ };
233
+ }
211
234
  if (!teamCheckRequested(args, config)) {
212
235
  return { halt: null, teamParliament: null, changedPaths: [] };
213
236
  }
@@ -301,7 +324,24 @@ export function applyAgainstRatchet({
301
324
  };
302
325
  }
303
326
 
327
+ /** Cheap doctor-path probe: skip git spawns on non-repos (hook-path bench tmpdirs). */
328
+ function gitDirPresent(root) {
329
+ let dir = path.resolve(root);
330
+ for (let i = 0; i < 10; i += 1) {
331
+ try {
332
+ if (fs.existsSync(path.join(dir, '.git'))) return true;
333
+ } catch {
334
+ return false;
335
+ }
336
+ const parent = path.dirname(dir);
337
+ if (parent === dir) break;
338
+ dir = parent;
339
+ }
340
+ return false;
341
+ }
342
+
304
343
  function gitAuthors(root) {
344
+ if (!gitDirPresent(root)) return [];
305
345
  const log = runGit(root, ['log', '--format=%aN<%aE>', '--max-count=300']);
306
346
  if (log.status !== 0) return [];
307
347
  const ids = [];
@@ -328,11 +368,35 @@ function readCodeowners(root) {
328
368
  return [];
329
369
  }
330
370
 
331
- /** Advisory only. Never flips a gate. Missing git is honest empty, not green. */
332
- export function collectStewardNudge(root, config) {
371
+ function gitFirstAddIso(root, relPath) {
372
+ if (!gitDirPresent(root)) return null;
373
+ const log = runGit(root, ['log', '--diff-filter=A', '--follow', '--format=%cI', '--', relPath]);
374
+ if (log.status !== 0) return null;
375
+ const lines = log.stdout
376
+ .split('\n')
377
+ .map((line) => line.trim())
378
+ .filter(Boolean);
379
+ return lines.length > 0 ? lines[lines.length - 1] : null;
380
+ }
381
+
382
+ /** Tooling clock: git first-add of ark.config.json vs injected `now`. Domain never clocks. */
383
+ export function adoptAgeDaysFromGit(root, relPath, now) {
384
+ const iso = gitFirstAddIso(root, relPath);
385
+ if (!iso) return { days: null, source: 'unavailable' };
386
+ const then = Date.parse(iso);
387
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
388
+ if (!Number.isFinite(then) || !Number.isFinite(nowMs)) return { days: null, source: 'unavailable' };
389
+ return { days: Math.floor((nowMs - then) / 86_400_000), source: 'git-first-add' };
390
+ }
391
+
392
+ /** Advisory residual. Never flips `valid` / `goal.met`. Missing git is unknown age, not green. */
393
+ export function collectStewardNudge(root, config, options = {}) {
394
+ const now = options.now instanceof Date ? options.now : options.now != null ? new Date(options.now) : new Date();
395
+ const age = adoptAgeDaysFromGit(root, options.configRel || 'ark.config.json', now);
333
396
  return suggestStewards({
334
397
  existingStewards: teamStewardsFromConfig(config),
335
398
  gitAuthors: gitAuthors(root),
336
399
  codeowners: readCodeowners(root),
400
+ adoptAgeDays: age.days,
337
401
  });
338
402
  }
@@ -198,8 +198,9 @@ export function parseCodeownersHandles(text) {
198
198
  }
199
199
  /**
200
200
  * Empty list + several hands → propose owners.
201
+ * Empty list + grace elapsed or unknown age → unfinished residual (not a new operating mode).
201
202
  * Existing list + CODEOWNERS ahead or author count grew → show the gap.
202
- * Never a gate input. Propose GitHub handles or emails — never git display names. Never auto-remove.
203
+ * Never a layer / `valid` / `goal.met` input. Propose GitHub handles or emails — never git display names.
203
204
  */
204
205
  export function suggestStewards(input) {
205
206
  const existing = [
@@ -224,7 +225,9 @@ export function suggestStewards(input) {
224
225
  ];
225
226
  const authorCount = Math.max(uniqueGit.length, fromOwners.length);
226
227
  const multiHand = uniqueGit.length >= 2 || fromOwners.length >= 1;
227
- const needsStewards = multiHand && existing.length === 0;
228
+ const age = input.adoptAgeDays;
229
+ const emptyStewardsPastGrace = existing.length === 0 && (age === null || (typeof age === 'number' && age >= 30));
230
+ const needsStewards = existing.length === 0 && (multiHand || emptyStewardsPastGrace);
228
231
  const missingFromList = fromOwners.filter((id) => !existing.includes(id));
229
232
  const teamGrew = existing.length > 0 &&
230
233
  fromOwners.length === 0 &&
@@ -238,24 +241,29 @@ export function suggestStewards(input) {
238
241
  const named = proposed.map((id) => formatStewardMention(id)).join(', ');
239
242
  const listed = existing.map((id) => formatStewardMention(id)).join(', ');
240
243
  let ask = '';
241
- if (needsStewards) {
244
+ if (needsStewards && multiHand) {
242
245
  ask =
243
246
  proposed.length > 0
244
247
  ? `This repo has several people and no stewards. Add ${named} as stewards so only they can loosen the law or grow the baseline? Say yes, or name the GitHub handles or emails.`
245
248
  : 'This repo has several people and no stewards. Who owns ark.config.json? Name GitHub handles or emails for the stewards[] list.';
246
249
  }
250
+ else if (needsStewards) {
251
+ ask =
252
+ 'No stewards listed. Name GitHub handles or emails for `stewards[]`, or this stays Adapt-or-nudge — not a finished Enforce. `/ark-adopt` asks; it does not invent names.';
253
+ }
247
254
  else if (missingFromList.length > 0) {
248
255
  ask = `CODEOWNERS is ahead of stewards[]: add ${missingFromList.map((id) => formatStewardMention(id)).join(', ')}? The current list stays unless you say yes or name the GitHub handles or emails.`;
249
256
  }
250
257
  else if (teamGrew) {
251
258
  ask = `This repo started with ${existing.length} steward(s) (${listed}) and now has ${uniqueGit.length} recent git authors. Who else owns the law? Name GitHub handles or emails, or say the list is still right.`;
252
259
  }
253
- const shouldAct = needsStewards || drift;
260
+ const shouldAct = needsStewards || drift || emptyStewardsPastGrace;
254
261
  return {
255
262
  advisory: true,
256
263
  notAScore: true,
257
264
  multiHand,
258
265
  needsStewards,
266
+ emptyStewardsPastGrace,
259
267
  drift,
260
268
  authorCount,
261
269
  stewardCount: existing.length,
@@ -266,6 +274,7 @@ export function suggestStewards(input) {
266
274
  nextAction: shouldAct
267
275
  ? '/ark-adopt (ask, then update stewards[] — do not invent names)'
268
276
  : '',
277
+ adoptAgeDays: typeof age === 'number' ? age : null,
269
278
  };
270
279
  }
271
280
  export function personaCheckBudget(persona) {
@@ -310,7 +319,8 @@ export function isTeamPersona(value) {
310
319
  /**
311
320
  * Gate for a diff vs the merge base.
312
321
  * Contract session still forbids mixing law with product source.
313
- * Loosen / baseline-grow require a steward when the list is non-empty.
322
+ * Loosen / baseline-grow require --contract-session even when stewards is empty.
323
+ * A non-empty list additionally requires a matching listed author.
314
324
  */
315
325
  export function evaluateTeamGate(input) {
316
326
  const kinds = [];
@@ -338,6 +348,16 @@ export function evaluateTeamGate(input) {
338
348
  const stewards = input.stewards ?? [];
339
349
  const grow = (input.baselineGrowCount ?? 0) > 0;
340
350
  const loosen = input.policyKind === 'loosen';
351
+ if ((loosen || grow) && !input.contractSession) {
352
+ return {
353
+ deny: true,
354
+ reasonId: loosen ? 'steward-only-loosen' : 'steward-only-baseline-grow',
355
+ message: loosen
356
+ ? 'Weakening the contract requires --contract-session (and --policy-ack bound to both hashes).'
357
+ : 'Growing the baseline requires --contract-session. Freeze in a law-only PR.',
358
+ kinds,
359
+ };
360
+ }
341
361
  if (stewards.length > 0 && (loosen || grow) && !isSteward(input.author, stewards)) {
342
362
  return {
343
363
  deny: true,
@@ -45,6 +45,7 @@ export function reportUnavailableAnalysis({
45
45
  configWalkedUp: args.configWalkedUp === true,
46
46
  parseHealth,
47
47
  completeness,
48
+ all: args.all === true,
48
49
  });
49
50
  process.exitCode = 2;
50
51
  return;