@rness/cli 0.17.3 → 0.18.1

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.
@@ -508,7 +508,7 @@ function parseRepoSpec(spec, org, host) {
508
508
  }
509
509
 
510
510
  // src/version.ts
511
- var VERSION = "0.17.3";
511
+ var VERSION = "0.18.1";
512
512
 
513
513
  // src/core/pm.ts
514
514
  import { execFile as execFile2 } from "child_process";
@@ -741,6 +741,25 @@ async function assembleContext({
741
741
 
742
742
  // src/core/contract.ts
743
743
  import { join as join5 } from "path";
744
+
745
+ // src/core/documents.ts
746
+ import { posix as posix2 } from "path";
747
+ function documentDate(rel) {
748
+ return /^((?:19|20)\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01]))-/.exec(
749
+ posix2.basename(rel)
750
+ )?.[1] ?? null;
751
+ }
752
+ function documentNumber(rel) {
753
+ if (documentDate(rel) !== null) return null;
754
+ return /^(\d{4})-/.exec(posix2.basename(rel))?.[1] ?? null;
755
+ }
756
+ function documentTitle(heading, number) {
757
+ if (number === null) return heading;
758
+ const rest = new RegExp(`^${number}\\s*[\u2014\u2013:-]\\s*(.+)$`).exec(heading)?.[1];
759
+ return rest ?? heading;
760
+ }
761
+
762
+ // src/core/contract.ts
744
763
  var STATUSES = {
745
764
  adr: ["Proposed", "Accepted", "Rejected", "Superseded"],
746
765
  specs: [
@@ -760,9 +779,31 @@ async function checkContract(rnessDir) {
760
779
  for (const name of COLLECTIONS) {
761
780
  const allowed = STATUSES[name];
762
781
  const items = await collectMarkdown(join5(rnessDir, name));
782
+ const byNumber = /* @__PURE__ */ new Map();
763
783
  for (const item of items) {
764
784
  const where = `${name}/${item.rel}`;
765
785
  if (SKIP.has(where)) continue;
786
+ const number = allowed === void 0 ? null : documentNumber(item.rel);
787
+ if (number !== null)
788
+ byNumber.set(number, [...byNumber.get(number) ?? [], where]);
789
+ }
790
+ for (const [number, files] of byNumber) {
791
+ if (files.length < 2) continue;
792
+ for (const where of files)
793
+ problems.push(
794
+ `${where}: the number ${number} is also that of ${files.filter((f) => f !== where).join(", ")}`
795
+ );
796
+ }
797
+ const next = String(
798
+ Math.max(0, ...[...byNumber.keys()].map(Number)) + 1
799
+ ).padStart(4, "0");
800
+ for (const item of items) {
801
+ const where = `${name}/${item.rel}`;
802
+ if (SKIP.has(where)) continue;
803
+ if (allowed !== void 0 && documentNumber(item.rel) === null)
804
+ problems.push(
805
+ `${where}: not numbered; name it ${next}-<slug>.md, the next number of ${name}`
806
+ );
766
807
  if (item.fieldsError !== null) {
767
808
  problems.push(`${where}: invalid front matter (${item.fieldsError})`);
768
809
  continue;
@@ -808,6 +849,9 @@ var PULSE_SCOPE = "project";
808
849
  function clientId() {
809
850
  return process.env["RNESS_GITHUB_CLIENT_ID"] ?? GITHUB_CLIENT_ID;
810
851
  }
852
+ function approvalUrl() {
853
+ return `https://github.com/settings/connections/applications/${clientId()}`;
854
+ }
811
855
  function webBase() {
812
856
  return (process.env["RNESS_GITHUB_WEB"] ?? GITHUB_WEB).replace(/\/+$/, "");
813
857
  }
@@ -1063,19 +1107,6 @@ async function resolveToken(deps = {}) {
1063
1107
  // src/core/status.ts
1064
1108
  import { readdir } from "fs/promises";
1065
1109
  import { join as join7, posix as posix3 } from "path";
1066
-
1067
- // src/core/documents.ts
1068
- import { posix as posix2 } from "path";
1069
- function documentNumber(rel) {
1070
- return /^(\d{4})-/.exec(posix2.basename(rel))?.[1] ?? null;
1071
- }
1072
- function documentTitle(heading, number) {
1073
- if (number === null) return heading;
1074
- const rest = new RegExp(`^${number}\\s*[\u2014\u2013:-]\\s*(.+)$`).exec(heading)?.[1];
1075
- return rest ?? heading;
1076
- }
1077
-
1078
- // src/core/status.ts
1079
1110
  var FIXED = [
1080
1111
  ["adr", "ADR"],
1081
1112
  ["specs", "Specs"],
@@ -1109,8 +1140,8 @@ function sessionsOf(item) {
1109
1140
  }
1110
1141
  function rowOf(dir, item) {
1111
1142
  const base = posix3.basename(item.rel, ".md");
1112
- const date = /^(\d{4}-\d{2}-\d{2})-/.exec(base)?.[1] ?? null;
1113
- const number = date === null ? documentNumber(item.rel) : null;
1143
+ const date = documentDate(item.rel);
1144
+ const number = documentNumber(item.rel);
1114
1145
  const sessions = sessionsOf(item);
1115
1146
  return {
1116
1147
  id: date ?? number ?? base,
@@ -2052,16 +2083,26 @@ async function getUser(options) {
2052
2083
  throw new Error(`GitHub API answered ${status2} for /user`);
2053
2084
  return { login };
2054
2085
  }
2055
- async function listUserOrganizations(options) {
2086
+ async function listUserMemberships(options) {
2056
2087
  const { status: status2, body } = await getJson(
2057
- `/user/orgs?per_page=${PER_PAGE}`,
2088
+ `/user/memberships/orgs?state=active&per_page=${PER_PAGE}`,
2058
2089
  options
2059
2090
  );
2060
2091
  if (status2 !== 200 || !Array.isArray(body))
2061
- throw new Error(`GitHub API answered ${status2} for /user/orgs`);
2062
- return body.map(
2063
- (o) => o !== null && typeof o === "object" ? o["login"] : void 0
2064
- ).filter((l) => typeof l === "string");
2092
+ throw new Error(`GitHub API answered ${status2} for /user/memberships/orgs`);
2093
+ const memberships = [];
2094
+ for (const m of body) {
2095
+ if (m === null || typeof m !== "object") continue;
2096
+ const entry = m;
2097
+ const org = entry["organization"];
2098
+ const login = org !== null && typeof org === "object" ? org["login"] : void 0;
2099
+ if (typeof login !== "string" || entry["state"] !== "active") continue;
2100
+ memberships.push({
2101
+ login,
2102
+ role: typeof entry["role"] === "string" ? entry["role"] : ""
2103
+ });
2104
+ }
2105
+ return memberships;
2065
2106
  }
2066
2107
  async function getOrgMembership(org, options) {
2067
2108
  const { status: status2, body } = await getJson(
@@ -3663,8 +3704,15 @@ var GitHubOAuthProvider = class {
3663
3704
  }
3664
3705
  async listOrganizations() {
3665
3706
  if (this.#token === null) return [];
3666
- const logins = await listUserOrganizations(this.#api(this.#token.token));
3667
- return logins.map((login) => ({ login }));
3707
+ const memberships = await listUserMemberships(this.#api(this.#token.token));
3708
+ return memberships.map((m) => ({
3709
+ login: m.login,
3710
+ approved: true,
3711
+ canGrant: m.role === "admin"
3712
+ }));
3713
+ }
3714
+ approvalUrl() {
3715
+ return this.#token?.source === "login" ? approvalUrl() : null;
3668
3716
  }
3669
3717
  async organizationAccess(org) {
3670
3718
  if (this.#token === null) return "unknown";
@@ -3913,6 +3961,9 @@ function inverse(text, stream = process.stdout) {
3913
3961
  const painted = styleText("inverse", text, { stream, validateStream: true });
3914
3962
  return painted === text ? plain : painted;
3915
3963
  }
3964
+ function count(n, [one, many]) {
3965
+ return `${n} ${n === 1 ? one : many}`;
3966
+ }
3916
3967
  function grey(text, stream = process.stdout) {
3917
3968
  if (noColor()) return text;
3918
3969
  return styleText("gray", text, { stream, validateStream: true });
@@ -4059,6 +4110,12 @@ function agentHintLines(platform = process.platform) {
4059
4110
  ` ~/.ssh/config: AddKeysToAgent yes${mac ? ", UseKeychain yes" : ""}`
4060
4111
  ];
4061
4112
  }
4113
+ function passphraseHint(platform = process.platform) {
4114
+ return [
4115
+ "ssh asked for your passphrase, and asks again for every repository unless an agent holds the key:",
4116
+ ...agentHintLines(platform).slice(1)
4117
+ ].join("\n");
4118
+ }
4062
4119
  function sshWorkspaceLines(reason3) {
4063
4120
  return [
4064
4121
  `this workspace clones over SSH (rness.json) but ssh to github.com fails: ${reason3}`,
@@ -4308,7 +4365,7 @@ async function ensureClaudeMd(dir) {
4308
4365
  import { join as join15 } from "path";
4309
4366
 
4310
4367
  // src/core/agent-targets.ts
4311
- import { mkdir as mkdir2 } from "fs/promises";
4368
+ import { mkdir as mkdir2, rm as rm4, rmdir } from "fs/promises";
4312
4369
  import { dirname as dirname4, join as join14 } from "path";
4313
4370
 
4314
4371
  // src/core/claude-skills.ts
@@ -4317,7 +4374,7 @@ var PLUGIN_JSON = `${JSON.stringify(
4317
4374
  {
4318
4375
  $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
4319
4376
  name: "rness",
4320
- description: "The rness workspace in Claude Code: /rness:status, /rness:adr, /rness:spec, /rness:plan, /rness:done.",
4377
+ description: "The rness workspace in Claude Code: /rness:status, /rness:adr, /rness:spec, /rness:plan.",
4321
4378
  author: { name: "rness-dev", url: "https://rness.dev" },
4322
4379
  homepage: "https://rness.dev",
4323
4380
  repository: "https://github.com/rness-dev/rness",
@@ -4360,53 +4417,68 @@ terminal, from the workspace's \`.rness/\`: \`${localRunner(pm)} rness status\`.
4360
4417
  }
4361
4418
  var STATUS_RULE = `Move a status yourself when the work calls for it, and whenever the
4362
4419
  developer says so: a plan \`In progress\` as you start its tasks,
4363
- \`Completed\` once they pass; a specification \`Implemented\` with its last
4364
- plan; a document \`Superseded\` by the one that replaces it. Accepting a
4365
- decision \u2014 an ADR \`Accepted\`, a specification \`Approved\` \u2014 is the
4366
- developer's, unless they asked you to go ahead with the work.`;
4420
+ \`Completed\` once its proofs pass under \`check\`; a specification
4421
+ \`Implemented\` with its last plan; a document \`Superseded\` by the one that
4422
+ replaces it. Accepting an ADR is the developer's; a specification is
4423
+ \`Approved\` by \`/rness:plan from NNNN\`, which is their word for it.`;
4367
4424
  var COMMIT_RULE = `Commit each file you changed, in its own repository, and only the files
4368
4425
  you alone changed: add them by name, never \`git add -A\` nor \`commit -a\`.
4369
4426
  A file that had uncommitted changes before your first edit (\`git status\`
4370
4427
  tells you), or that someone else changed since, stays uncommitted: say
4371
4428
  which. Never push.`;
4429
+ var SESSION_LINE = `\`{ id: \${CLAUDE_SESSION_ID}, agent: <model> }\` at the end of
4430
+ \`sessions:\` in the front matter, unless that id is already there;
4431
+ \`<model>\` is the model you run as, as Claude Code names it, after
4432
+ \`Claude\`: for example \`Claude Opus 5.5\``;
4433
+ var allocateStep = (allocate, collection) => `Allocate the file: run \`${allocate}\` from the project directory, where
4434
+ Claude Code was opened (pre-approved above as typed; \`CLAUDE_PROJECT_DIR\`
4435
+ is not set in your shell, so the path is relative). It writes
4436
+ \`${collection}/NNNN-untitled.md\` with the next number, the front matter
4437
+ and the opening sections, and prints the path. Rename it to
4438
+ \`NNNN-<slug>.md\`, the slug a few lowercase words of the title joined by
4439
+ hyphens, and write the title as \`# NNNN \u2014 <title>\`. Never choose the
4440
+ number yourself.`;
4441
+ var HOOK_LINE = (rness) => `The rness hook checks every edit under \`${rness}/\`: fix what it reports.`;
4372
4442
  var LIFECYCLE2 = [
4373
4443
  {
4374
4444
  name: "adr",
4375
- description: "Record a decision of the rness workspace as an ADR in .rness/adr/ \u2014 a choice expensive to reverse or that creates a lasting constraint. Use when the developer asks for an ADR, or when the conversation reaches such a decision.",
4376
- hint: "[subject | NNNN]",
4445
+ description: "Record a decision of the rness workspace as an ADR in .rness/adr/ \u2014 a choice expensive to reverse or that creates a lasting constraint \u2014 or reopen one. Use when the developer asks for an ADR, or when the conversation reaches such a decision.",
4446
+ hint: "[create <subject> | open NNNN]",
4377
4447
  tab: "adr",
4378
- body: (rness) => `You record a decision of this workspace as an ADR in \`${rness}/adr/\`. The table
4379
- above lists the existing ADRs, newest first. Arguments: \`$ARGUMENTS\`.
4448
+ body: (rness, allocate) => `You keep the decisions of this workspace: the ADRs in \`${rness}/adr/\`. The table
4449
+ above lists the existing ones, newest first. Arguments: \`$ARGUMENTS\`.
4380
4450
 
4381
- Before writing:
4451
+ The first word is the verb, when a reference (\`0010\`, or a path) follows
4452
+ it; a bare reference is \`open\`; any other words are the subject of a
4453
+ \`create\` (\`open source licensing\` is a subject):
4454
+
4455
+ - \`create [subject]\`, or nothing: a new ADR, numbered by rness.
4456
+ - \`open NNNN\`: reopen ADR NNNN. An \`Accepted\` ADR is never edited: offer a
4457
+ new one that supersedes it. \`Rejected\` and \`Superseded\` are history too.
4458
+
4459
+ Creating:
4382
4460
 
4383
4461
  1. If the developer did not ask for an ADR in so many words, propose one in
4384
4462
  a single line \u2014 its title, and why the choice is hard to reverse \u2014 and
4385
4463
  wait for a yes.
4386
4464
  2. Read \`${rness}/CONVENTIONS.md\` and \`${rness}/adr/0000-template.md\`: they hold the
4387
4465
  rules and the sections; this skill is only the procedure.
4388
- 3. Arguments naming an ADR (\`NNNN\`): reopen that file. An \`Accepted\` ADR is
4389
- never edited: offer a new ADR that supersedes it.
4390
- 4. Otherwise the subject is the arguments, else the decision just
4391
- discussed. Context, alternatives or consequences missing from the
4392
- conversation: ask for them, one question at a time. Never invent them.
4393
-
4394
- Writing:
4395
-
4396
- - \`${rness}/adr/NNNN-<slug>.md\`, NNNN the highest number above plus one.
4397
- - The template's front matter and sections, filled in, its guidance lines
4398
- removed: \`date\` today, \`status: Proposed\`, \`repo\` the repositories under
4399
- \`org/\` it affects.
4400
- - An ADR it replaces is named in Context. That one becomes \`Superseded\`,
4401
- with \`superseded_by:\`, only once the new ADR is \`Accepted\`.
4402
- - Add \`{ id: \${CLAUDE_SESSION_ID}, agent: <model> }\` at the end of
4403
- \`sessions:\` in the front matter, unless that id is already there.
4404
- \`<model>\` is the model you run as, as Claude Code names it, after
4405
- \`Claude\`: for example \`Claude Opus 5.5\`.
4406
- - The rness hook checks every edit under \`${rness}/\`: fix what it reports.
4466
+ 3. The subject is the arguments, else the decision just discussed. Context,
4467
+ alternatives or consequences missing from the conversation: ask for
4468
+ them, one question at a time. Never invent them.
4469
+ 4. ${allocateStep(allocate, "adr")}
4470
+ 5. Fill it: \`repo\` the repositories under \`org/\` it affects; the template's
4471
+ sections, their guidance lines removed; an ADR it replaces named in
4472
+ Context \u2014 that one becomes \`Superseded\`, with \`superseded_by:\`, only once
4473
+ the new ADR is \`Accepted\`. Add ${SESSION_LINE}.
4474
+ 6. ${HOOK_LINE(rness)}
4407
4475
 
4408
- After writing: give the path and the Decision section. The ADR stays
4409
- \`Proposed\` until it is accepted.
4476
+ Opening: read the ADR and amend what the developer asks, in place while it
4477
+ is \`Proposed\`; add the session line above. Nothing of the file's history is
4478
+ rewritten: a decision that changed is a new ADR.
4479
+
4480
+ After writing: give the path and the Decision section. A new ADR stays
4481
+ \`Proposed\` until the developer accepts it.
4410
4482
 
4411
4483
  ${STATUS_RULE}
4412
4484
 
@@ -4415,43 +4487,47 @@ ${COMMIT_RULE}
4415
4487
  },
4416
4488
  {
4417
4489
  name: "spec",
4418
- description: "Write a specification of the rness workspace in .rness/specs/ \u2014 the outcome wanted and its scope, before any plan. Use when the developer asks for a spec, or when agreed work is too large to start without one.",
4419
- hint: "[subject | NNNN]",
4490
+ description: "Write a specification of the rness workspace in .rness/specs/ \u2014 the outcome wanted and its scope, before any plan \u2014 or reopen one. Use when the developer asks for a spec, or when agreed work is too large to start without one.",
4491
+ hint: "[create <subject> | open NNNN]",
4420
4492
  tab: "specs",
4421
- body: (rness) => `You write a specification of this workspace in \`${rness}/specs/\`. The table above
4422
- lists the existing ones, newest first. Arguments: \`$ARGUMENTS\`.
4493
+ body: (rness, allocate) => `You keep the specifications of this workspace, in \`${rness}/specs/\`. The table
4494
+ above lists the existing ones, newest first. Arguments: \`$ARGUMENTS\`.
4495
+
4496
+ The first word is the verb, when a reference (\`0028\`, or a path) follows
4497
+ it; a bare reference is \`open\`; any other words are the subject of a
4498
+ \`create\`:
4499
+
4500
+ - \`create [subject]\`, or nothing: a new specification, numbered by rness.
4501
+ - \`open NNNN\`: reopen specification NNNN. One that is \`Implemented\`,
4502
+ \`Superseded\` or \`Rejected\` is not edited: offer a new one.
4423
4503
 
4424
- Before writing:
4504
+ Creating:
4425
4505
 
4426
4506
  1. If the developer did not ask for a specification in so many words,
4427
4507
  propose one in a single line and wait for a yes.
4428
4508
  2. Read \`${rness}/CONVENTIONS.md\`, then the most recent specification above: match
4429
4509
  its front matter, sections and tone.
4430
- 3. Arguments naming a specification (\`NNNN\`): reopen it. One that is
4431
- \`Implemented\`, \`Superseded\` or \`Rejected\` is not edited: offer a new one.
4432
- 4. Otherwise the subject is the arguments, else the work just discussed.
4433
- The problem, what is in and out of scope, the ADRs it rests on: ask what
4434
- the conversation does not say, one question at a time.
4435
-
4436
- Writing:
4437
-
4438
- - \`${rness}/specs/NNNN-<slug>.md\`, NNNN the highest number above plus one.
4439
- - \`date\` and \`updated\` today, \`status: Draft\`, \`repo\`, and \`adr:\` linking
4440
- the decisions it rests on \u2014 or the rationale in the body when none does.
4441
- - No specification yet to follow: Summary, Scope, Out of scope,
4442
- Alternatives considered, To verify, Tests.
4443
- - What was checked in this session (a document read, a command run) is
4444
- written as verified, with the date; everything else as a proposal or an
4445
- open question.
4446
- - Add \`{ id: \${CLAUDE_SESSION_ID}, agent: <model> }\` at the end of
4447
- \`sessions:\` in the front matter, unless that id is already there.
4448
- \`<model>\` is the model you run as, as Claude Code names it, after
4449
- \`Claude\`: for example \`Claude Opus 5.5\`.
4450
- - The rness hook checks every edit under \`${rness}/\`: fix what it reports.
4510
+ 3. The subject is the arguments, else the work just discussed. The problem,
4511
+ what is in and out of scope, the ADRs it rests on: ask what the
4512
+ conversation does not say, one question at a time.
4513
+ 4. ${allocateStep(allocate, "specs")}
4514
+ 5. Fill it: \`repo\`, and \`adr:\` linking the decisions it rests on \u2014 or the
4515
+ rationale in the body when none does; the sections it opened with
4516
+ (Summary, Scope, Out of scope, Alternatives considered, To verify,
4517
+ Tests), or the most recent specification's when the team's differ. What
4518
+ was checked in this session (a document read, a command run) is written
4519
+ as verified, with the date; everything else as a proposal or an open
4520
+ question. Add ${SESSION_LINE}.
4521
+ 6. ${HOOK_LINE(rness)}
4522
+
4523
+ Opening: read the specification and amend what the developer asks \u2014 a
4524
+ section, a scope, an open question answered \u2014 \`updated\` today, the session
4525
+ line added. A status moves only as the developer says, or by
4526
+ \`/rness:plan from\`.
4451
4527
 
4452
- After writing: give the path and the summary. It starts \`Draft\`:
4453
- \`Proposed\` for review, \`Approved\` to plan it. No plan here:
4454
- \`/rness:plan NNNN\` once approved.
4528
+ After writing: give the path and the summary. A new specification starts
4529
+ \`Draft\`: \`Proposed\` for review; \`/rness:plan from NNNN\` approves it and
4530
+ plans it.
4455
4531
 
4456
4532
  ${STATUS_RULE}
4457
4533
 
@@ -4460,105 +4536,104 @@ ${COMMIT_RULE}
4460
4536
  },
4461
4537
  {
4462
4538
  name: "plan",
4463
- description: "Turn an approved specification of the rness workspace into a plan in .rness/plans/ \u2014 ordered tasks, each with the command that proves it done. Use when the developer asks for a plan, or when a specification has just been approved.",
4464
- hint: "<spec NNNN>",
4539
+ description: "The plans of the rness workspace in .rness/plans/ \u2014 a plan from a specification (from, which approves it), from the conversation (create), reopened (open), or closed on evidence (check). Use when the developer asks for a plan, to approve a specification, or to check the work of a plan.",
4540
+ hint: "[create <subject> | from <spec> | open NNNN | check NNNN]",
4465
4541
  tab: "plans",
4466
- body: (rness) => `You turn a specification into a plan in \`${rness}/plans/\`. The table above lists
4467
- the existing plans, newest first. Arguments, the specification:
4468
- \`$ARGUMENTS\`.
4469
-
4470
- Before writing:
4471
-
4472
- 1. If the developer did not ask for a plan in so many words, propose one in
4473
- a single line and wait for a yes.
4474
- 2. No specification named: ask which. Read it, \`${rness}/CONVENTIONS.md\`, and the
4475
- most recent plan above: match its front matter and sections.
4476
- 3. A specification that is not \`Approved\`: say so, and plan it only if the
4477
- developer confirms. A plan of it that is neither \`Completed\` nor
4478
- \`Abandoned\`: reopen it rather than write a second.
4479
- 4. Read the code and the repositories the specification touches: every
4480
- task names real files and real commands.
4542
+ body: (rness, allocate) => `You keep the plans of this workspace, in \`${rness}/plans/\`. The table above
4543
+ lists the existing ones, newest first. Arguments: \`$ARGUMENTS\`.
4481
4544
 
4482
- Writing:
4483
-
4484
- - \`${rness}/plans/NNNN-<slug>.md\`, NNNN the highest plan number above plus one \u2014
4485
- not the specification's.
4486
- - \`date\` and \`updated\` today, \`status: Draft\`, \`repo\`, \`spec:\` linking the
4487
- specification.
4488
- - Tasks in order, as checkboxes. Each gives the files, what changes, and
4489
- the command that proves it (a test, a build, a run). The last tasks:
4490
- the documentation the work makes inaccurate, then the release when the
4491
- repository has one.
4492
- - Where the plan decides something the specification leaves open, a
4493
- section says so.
4494
- - Add \`{ id: \${CLAUDE_SESSION_ID}, agent: <model> }\` at the end of
4495
- \`sessions:\` in the front matter, unless that id is already there.
4496
- \`<model>\` is the model you run as, as Claude Code names it, after
4497
- \`Claude\`: for example \`Claude Opus 5.5\`.
4498
- - The rness hook checks every edit under \`${rness}/\`: fix what it reports.
4545
+ The first word is the verb, when a reference (\`0037\`, or a path) follows
4546
+ it \u2014 \`check\` alone means the plan \`In progress\`; a bare reference is
4547
+ \`open\`; any other words are the subject of a \`create\`:
4499
4548
 
4500
- After writing: give the path and the list of tasks. It starts \`Draft\`, and
4501
- becomes \`Ready\` once the developer agrees with it or asks you to carry it
4502
- out.
4549
+ - \`from NNNN\`: a plan from specification NNNN, which this approves.
4550
+ - \`create [subject]\`, or nothing: a plan from the conversation, for work
4551
+ too small for a specification; \`spec:\` is left empty.
4552
+ - \`open NNNN\`: reopen plan NNNN.
4553
+ - \`check NNNN\`: run the proofs of plan NNNN; close it when they all pass.
4503
4554
 
4504
- ${STATUS_RULE}
4555
+ \`from\`:
4505
4556
 
4506
- ${COMMIT_RULE}
4507
- `
4508
- },
4509
- {
4510
- name: "done",
4511
- description: "Close a piece of work of the rness workspace \u2014 check each task of its plan on evidence, then move the plan and its specification to their final status and correct the documents the work made inaccurate. Use when the developer says the work is done, or when the last task of a plan passes.",
4512
- hint: "[plan NNNN]",
4513
- tab: "plans",
4514
- body: (rness) => `You close a piece of work: its plan in \`${rness}/plans/\`, its specification, and
4515
- what they made inaccurate. The table above lists the plans, newest first.
4516
- Arguments: \`$ARGUMENTS\`.
4557
+ 1. Read the specification, \`${rness}/CONVENTIONS.md\`, and the most recent plan
4558
+ above: match its front matter and sections. A specification that is
4559
+ \`Implemented\`, \`Superseded\` or \`Rejected\` is refused: say its status,
4560
+ write nothing. A plan of it that is neither \`Completed\` nor \`Abandoned\`:
4561
+ reopen it rather than write a second.
4562
+ 2. The developer typed this command: that is their approval, and the
4563
+ specification moves from \`Draft\` or \`Proposed\` to \`Approved\`, \`updated\`
4564
+ today, in the same commit as the plan \u2014 no confirmation question. If
4565
+ you are proposing it yourself, propose it in a single line and wait for
4566
+ a yes first: the approval is theirs, never yours.
4567
+ 3. Read the code and the repositories the specification touches: every
4568
+ task names real files and real commands.
4569
+ 4. ${allocateStep(allocate, "plans")}
4570
+ 5. Fill it: \`spec:\` linking the specification; tasks in order, as
4571
+ checkboxes, each with the files, what changes, and the command that
4572
+ proves it (a test, a build, a run) \u2014 the last tasks the documentation
4573
+ the work makes inaccurate, then the release when the repository has
4574
+ one; where the plan decides something the specification leaves open, the
4575
+ section it opened with says so. Add ${SESSION_LINE}.
4576
+ 6. ${HOOK_LINE(rness)}
4577
+
4578
+ \`create\`: as \`from\`, without a specification. If the developer did not ask
4579
+ for a plan in so many words, propose one in a single line and wait for a
4580
+ yes. The body says what the work is and why it needs no specification.
4581
+
4582
+ \`open\`: read the plan and its specification; amend what the developer
4583
+ asks \u2014 a task added, split or reworded, a blocker recorded \u2014 \`updated\`
4584
+ today, the session line added; move \`Draft\` to \`Ready\` or \`Blocked\` as they
4585
+ say. A \`Completed\` or \`Abandoned\` plan is not reopened: offer a new plan of
4586
+ the same specification.
4587
+
4588
+ \`check\`:
4517
4589
 
4518
4590
  1. The plan: the one named, else the one \`In progress\`. Several or none:
4519
4591
  ask.
4520
- 2. The developer asked to close it, or its last task has just passed in
4521
- this session: close it. Otherwise propose it in a single line and wait
4522
- for a yes.
4592
+ 2. The developer asked for the check, or the plan's last task has just
4593
+ passed in this session: run it. Otherwise propose it in a single line
4594
+ and wait for a yes.
4523
4595
  3. Read the plan, its specification and \`${rness}/CONVENTIONS.md\`.
4524
- 4. Evidence first. For each task, what shows it done: a test, a build, a
4525
- command's output in this session. A task without evidence: run its
4526
- check now, or report it. Never mark a task done on belief.
4527
- 5. All tasks proven:
4596
+ 4. Evidence first. For each task, what proves it: its command run now, or
4597
+ its result read in this session. Never mark a task done on belief. A
4598
+ check has no cache: run twice, it proves twice.
4599
+ 5. Every task proven:
4528
4600
  - the plan: \`status: Completed\`, \`updated\` today, the checkboxes of the
4529
4601
  proven tasks ticked, and a section \`Verification (YYYY-MM-DD)\` \u2014 what
4530
4602
  ran, what it showed, what could not run and why;
4531
4603
  - the specification: \`Implemented\` once every plan of it is
4532
4604
  \`Completed\`; \`updated\` today;
4533
- - the plan and the specification: add
4534
- \`{ id: \${CLAUDE_SESSION_ID}, agent: <model> }\` at the end of
4535
- \`sessions:\` in their front matter, unless that id is already there;
4536
- \`<model>\` is the model you run as, as Claude Code names it, after
4537
- \`Claude\`: for example \`Claude Opus 5.5\`;
4605
+ - the plan and the specification: add ${SESSION_LINE};
4538
4606
  - the smallest set of documents the work makes inaccurate: \`${rness}/docs/\`,
4539
4607
  the READMEs of the repositories it changed.
4540
- 6. A task not proven: the plan stays \`In progress\` \u2014 or \`Blocked\`, the
4541
- blocker written in it. Say what remains.
4542
- 7. The rness hook checks every edit under \`${rness}/\`: fix what it reports.
4608
+ 6. A task not proven: nothing moves, the plan stays \`In progress\`. Say,
4609
+ task by task, what passed and what remains. A blocker the developer
4610
+ names is a move of its own: \`Blocked\`, the blocker written in the plan.
4611
+ 7. ${HOOK_LINE(rness)}
4612
+
4613
+ After writing: give the path and the list of tasks, or the statuses moved.
4614
+ A new plan starts \`Draft\`, and becomes \`Ready\` once the developer agrees
4615
+ with it or asks you to carry it out.
4543
4616
 
4544
- After: list the files changed and the statuses moved.
4617
+ ${STATUS_RULE}
4545
4618
 
4546
4619
  ${COMMIT_RULE}
4547
4620
  `
4548
4621
  }
4549
4622
  ];
4623
+ var RETIRED_SKILLS = ["done"];
4550
4624
  function lifecycleSkill(rness, s) {
4551
4625
  const command = `node "\${CLAUDE_PROJECT_DIR}/${rness}/${PINNED_BIN}" status ${s.tab} --cwd "\${CLAUDE_PROJECT_DIR}"`;
4626
+ const allocate = `node "${rness}/${PINNED_BIN}" doc new ${s.tab}`;
4552
4627
  return `---
4553
4628
  name: ${s.name}
4554
4629
  description: ${s.description}
4555
4630
  argument-hint: '${s.hint}'
4556
- allowed-tools: Bash(${command})
4631
+ allowed-tools: Bash(${command}), Bash(${allocate})
4557
4632
  ---
4558
4633
 
4559
4634
  !\`${command}\`
4560
4635
 
4561
- ${s.body(rness)}`;
4636
+ ${s.body(rness, allocate)}`;
4562
4637
  }
4563
4638
  function pluginFiles(rness, at, ctx) {
4564
4639
  return [
@@ -4572,6 +4647,11 @@ function pluginFiles(rness, at, ctx) {
4572
4647
  file: `${PLUGIN}/skills/${s.name}/SKILL.md`,
4573
4648
  at,
4574
4649
  content: lifecycleSkill(rness, s)
4650
+ })),
4651
+ ...RETIRED_SKILLS.map((name) => ({
4652
+ file: `${PLUGIN}/skills/${name}/SKILL.md`,
4653
+ at,
4654
+ retired: true
4575
4655
  }))
4576
4656
  ];
4577
4657
  }
@@ -5097,6 +5177,21 @@ async function agentTargets(root, manifest, opts) {
5097
5177
  for (const owned of await filesOf(root, manifest, declared(manifest))) {
5098
5178
  const { file, label } = owned;
5099
5179
  const existing = await readOrNull(file);
5180
+ if ("retired" in owned) {
5181
+ if (existing === null) continue;
5182
+ if (opts.check) {
5183
+ outcomes.push({
5184
+ label,
5185
+ status: "stale",
5186
+ detail: "no longer written by rness; remove it"
5187
+ });
5188
+ continue;
5189
+ }
5190
+ await rm4(file);
5191
+ await rmdir(dirname4(file)).catch(() => void 0);
5192
+ outcomes.push({ label, status: "removed", detail: null });
5193
+ continue;
5194
+ }
5100
5195
  if ("content" in owned) {
5101
5196
  const diff = wholeDiff(existing, owned.content);
5102
5197
  if (diff === null)
@@ -5147,6 +5242,7 @@ async function leftoverTargets(root, manifest) {
5147
5242
  );
5148
5243
  const leftovers = [];
5149
5244
  for (const owned of await filesOf(root, manifest, undeclared)) {
5245
+ if ("retired" in owned) continue;
5150
5246
  const existing = await readOrNull(owned.file);
5151
5247
  if (existing === null) continue;
5152
5248
  if ("content" in owned) {
@@ -5435,7 +5531,7 @@ async function askWhatToSync(input) {
5435
5531
  {
5436
5532
  value: SELECT_ALL,
5437
5533
  label: "Select all",
5438
- hint: `${input.missing.length} repositories from rness.json`
5534
+ hint: `${count(input.missing.length, ["repository", "repositories"])} from rness.json`
5439
5535
  },
5440
5536
  ...input.missing.map((name) => ({ value: name, label: name }))
5441
5537
  ],
@@ -5542,6 +5638,7 @@ async function syncCommand(opts, deps = {}) {
5542
5638
  () => ui.line(...CHECKING)
5543
5639
  );
5544
5640
  if (unattended) ui.gitIsSilent = true;
5641
+ else if (access2.ok) ui.hint(passphraseHint(), "stderr");
5545
5642
  if (!access2.ok) {
5546
5643
  process.stderr.write(
5547
5644
  `${[...sshWorkspaceLines(access2.reason), ...INSTEAD_OF_LINES].join("\n")}
@@ -5779,6 +5876,7 @@ async function addCommand(spec, opts, deps = {}) {
5779
5876
  () => ui.line(...CHECKING)
5780
5877
  );
5781
5878
  if (unattended || !access2.ok) ui.gitIsSilent = true;
5879
+ else ui.hint(passphraseHint(), "stderr");
5782
5880
  const overSsh = Object.values(manifest.repos).some(
5783
5881
  (r) => isSshUrl(r.url, transport.hosts)
5784
5882
  );
@@ -6000,9 +6098,23 @@ async function copyScaffold(dest, tokens) {
6000
6098
  }
6001
6099
 
6002
6100
  // src/commands/login.ts
6003
- import { spawn as spawn3 } from "child_process";
6004
6101
  import { realpath } from "fs/promises";
6005
6102
 
6103
+ // src/core/browser.ts
6104
+ import { spawn as spawn3 } from "child_process";
6105
+ function openInBrowser(url) {
6106
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
6107
+ try {
6108
+ const child = spawn3(command, args, { stdio: "ignore", detached: true });
6109
+ child.on("error", () => void 0);
6110
+ child.unref();
6111
+ } catch {
6112
+ }
6113
+ }
6114
+ function canOpenBrowser() {
6115
+ return process.env["SSH_CONNECTION"] === void 0;
6116
+ }
6117
+
6006
6118
  // src/core/setup-git.ts
6007
6119
  import { execFile as execFile4 } from "child_process";
6008
6120
  import { promisify as promisify4 } from "util";
@@ -6047,15 +6159,6 @@ async function removeGitSetup() {
6047
6159
  }
6048
6160
 
6049
6161
  // src/commands/login.ts
6050
- function openInBrowser(url) {
6051
- const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
6052
- try {
6053
- const child = spawn3(command, args, { stdio: "ignore", detached: true });
6054
- child.on("error", () => void 0);
6055
- child.unref();
6056
- } catch {
6057
- }
6058
- }
6059
6162
  async function offerSetupGit(opts, terminal, bin, ui) {
6060
6163
  if (opts.setupGit === false) return;
6061
6164
  if (!isStableInstall(bin)) {
@@ -6132,7 +6235,7 @@ async function loginCommand(opts, deps = {}) {
6132
6235
  ui.line("open", code.verificationUri);
6133
6236
  ui.line("code", code.userCode);
6134
6237
  }
6135
- if (terminal.isTty() && process.env["SSH_CONNECTION"] === void 0)
6238
+ if (terminal.isTty() && canOpenBrowser())
6136
6239
  (deps.open ?? openInBrowser)(code.verificationUri);
6137
6240
  if (!ui.session)
6138
6241
  ui.line("waiting", "for you to approve rness on github.com\u2026");
@@ -6165,35 +6268,134 @@ async function loginCommand(opts, deps = {}) {
6165
6268
  return reportError(e);
6166
6269
  }
6167
6270
  }
6168
- function revokeUrl() {
6169
- return `https://github.com/settings/connections/applications/${clientId()}`;
6170
- }
6171
6271
 
6172
6272
  // src/commands/create.ts
6173
- import { mkdir as mkdir6, mkdtemp as mkdtemp2, rename as rename2, rm as rm6 } from "fs/promises";
6273
+ import { mkdir as mkdir6, mkdtemp as mkdtemp2, rename as rename2, rm as rm7 } from "fs/promises";
6174
6274
  import { tmpdir as tmpdir2 } from "os";
6175
6275
  import { join as join22, relative as relative4, resolve as resolve5 } from "path";
6176
6276
 
6277
+ // src/core/approval.ts
6278
+ var APPROVAL_INTERVAL_MS = 5e3;
6279
+ var APPROVAL_WAIT_MS = 10 * 6e4;
6280
+ function approvalQuestion(org) {
6281
+ return `Open github.com to approve rness for ${org} now?`;
6282
+ }
6283
+ async function checkAccess(input) {
6284
+ const { org, provider, ui } = input;
6285
+ if (!provider.authenticated) return "unknown";
6286
+ const login = (await provider.identity())?.login;
6287
+ const as = login === void 0 ? "" : ` (as ${login})`;
6288
+ const member = () => ui.line("member", `${org}${as}`, `You are a member of ${org}${as}`);
6289
+ const access2 = await provider.organizationAccess(org);
6290
+ if (access2 === "member") {
6291
+ member();
6292
+ return access2;
6293
+ }
6294
+ if (access2 !== "restricted") return access2;
6295
+ const url = provider.approvalUrl();
6296
+ ui.warn(
6297
+ `${org} has not approved rness, so its private repositories are hidden`
6298
+ );
6299
+ if (!input.interactive || url === null) {
6300
+ ui.hint(
6301
+ url === null ? `an owner of ${org} has to approve the app your token belongs to` : `an owner approves it at ${url}`,
6302
+ "stderr"
6303
+ );
6304
+ return "restricted";
6305
+ }
6306
+ const p = await input.prompts();
6307
+ const ok = await p.confirm({
6308
+ message: approvalQuestion(org),
6309
+ initialValue: true
6310
+ });
6311
+ if (p.isCancel(ok)) return "cancelled";
6312
+ if (ok !== true) {
6313
+ ui.hint(`approve it later: ${url}`, "stderr");
6314
+ return "restricted";
6315
+ }
6316
+ ui.line("open", url, `Open ${url} and approve rness for ${org}`);
6317
+ if (canOpenBrowser()) (input.deps?.open ?? openInBrowser)(url);
6318
+ const sleep = input.deps?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
6319
+ const now = input.deps?.now ?? Date.now;
6320
+ const who = input.canGrant === true ? org : `an owner of ${org}`;
6321
+ const waiting = `for ${who} to approve rness on github.com`;
6322
+ if (!ui.session) ui.line("waiting", `${waiting}\u2026`);
6323
+ let interrupted = false;
6324
+ let wake = () => void 0;
6325
+ const interrupt = () => {
6326
+ interrupted = true;
6327
+ wake();
6328
+ };
6329
+ if (!ui.session) process.once("SIGINT", interrupt);
6330
+ let outcome;
6331
+ try {
6332
+ outcome = await ui.step(
6333
+ {
6334
+ doing: `waiting ${waiting}`,
6335
+ sentence: `Waiting ${waiting}`,
6336
+ quiet: true
6337
+ },
6338
+ async () => {
6339
+ const deadline = now() + APPROVAL_WAIT_MS;
6340
+ for (; ; ) {
6341
+ await Promise.race([
6342
+ sleep(APPROVAL_INTERVAL_MS),
6343
+ new Promise((resolve8) => {
6344
+ wake = resolve8;
6345
+ })
6346
+ ]);
6347
+ if (interrupted) return "cancelled";
6348
+ const again = await provider.organizationAccess(org);
6349
+ if (again === "member" || again === "not-member") return again;
6350
+ if (now() >= deadline) return "timeout";
6351
+ }
6352
+ },
6353
+ (r) => r === "member" ? ["member", `${org}${as}`] : null
6354
+ );
6355
+ } finally {
6356
+ process.off("SIGINT", interrupt);
6357
+ }
6358
+ if (outcome === "cancelled") return outcome;
6359
+ if (outcome === "member") {
6360
+ if (!ui.session) member();
6361
+ return outcome;
6362
+ }
6363
+ if (outcome === "not-member") return outcome;
6364
+ ui.warn(
6365
+ `${org} still has not approved rness; its private repositories stay hidden this time`
6366
+ );
6367
+ ui.hint(
6368
+ `once an owner approves it, add its private repositories with ${rnessCommand()} add <repo>`,
6369
+ "stderr"
6370
+ );
6371
+ return "restricted";
6372
+ }
6373
+
6177
6374
  // src/core/ask-provider.ts
6178
6375
  var PROVIDER_QUESTION = "Where does your organization live?";
6376
+ var BLANK = "blank";
6377
+ var BLANK_LABEL = "No organization yet: a blank local workspace";
6179
6378
  async function askProvider(p) {
6180
6379
  return p.select({
6181
6380
  message: PROVIDER_QUESTION,
6182
- options: Object.values(PROVIDERS).map((entry) => ({
6183
- value: entry.name,
6184
- label: entry.available ? entry.label : `${entry.label} (coming later)`,
6185
- disabled: !entry.available
6186
- }))
6381
+ options: [
6382
+ ...Object.values(PROVIDERS).map((entry) => ({
6383
+ value: entry.name,
6384
+ label: entry.available ? entry.label : `${entry.label} (coming later)`,
6385
+ disabled: !entry.available
6386
+ })),
6387
+ { value: BLANK, label: BLANK_LABEL, disabled: false }
6388
+ ]
6187
6389
  });
6188
6390
  }
6189
6391
 
6190
6392
  // src/core/new-workspace.ts
6191
- import { mkdir as mkdir4, readdir as readdir4, rm as rm5, stat as stat3 } from "fs/promises";
6393
+ import { mkdir as mkdir4, readdir as readdir4, rm as rm6, stat as stat3 } from "fs/promises";
6192
6394
  import { dirname as dirname8, join as join20, relative as relative2 } from "path";
6193
6395
 
6194
6396
  // src/core/scaffold-git.ts
6195
6397
  import { execFile as execFile5 } from "child_process";
6196
- import { mkdtemp, realpath as realpath2, rm as rm4, writeFile as writeFile3 } from "fs/promises";
6398
+ import { mkdtemp, realpath as realpath2, rm as rm5, writeFile as writeFile3 } from "fs/promises";
6197
6399
  import { tmpdir } from "os";
6198
6400
  import { dirname as dirname7, join as join19, resolve as resolve3 } from "path";
6199
6401
  import { promisify as promisify5 } from "util";
@@ -6321,7 +6523,7 @@ async function buildScaffoldCommit(rnessDir, base, files, version) {
6321
6523
  scaffoldMessage(`chore: rness scaffold ${version}`, version)
6322
6524
  ]);
6323
6525
  } finally {
6324
- await rm4(scratch, { recursive: true, force: true });
6526
+ await rm5(scratch, { recursive: true, force: true });
6325
6527
  }
6326
6528
  }
6327
6529
  async function blobAt(rnessDir, rev, path) {
@@ -6445,9 +6647,9 @@ async function buildContext(input) {
6445
6647
  return true;
6446
6648
  } catch (e) {
6447
6649
  reportError(e);
6448
- await rm5(rnessDir, { recursive: true, force: true }).catch(() => void 0);
6650
+ await rm6(rnessDir, { recursive: true, force: true }).catch(() => void 0);
6449
6651
  if (await isEmptyDir(root).catch(() => false))
6450
- await rm5(root, { recursive: true, force: true }).catch(() => void 0);
6652
+ await rm6(root, { recursive: true, force: true }).catch(() => void 0);
6451
6653
  process.stderr.write(
6452
6654
  `removed ${shown}/.rness after the failure; fix it and retry
6453
6655
  `
@@ -6630,22 +6832,6 @@ function cancelled(ui) {
6630
6832
  }
6631
6833
  async function pickRepositories(input) {
6632
6834
  const { org, prompts, provider, ui } = input;
6633
- if (provider.authenticated) {
6634
- const access2 = await provider.organizationAccess(org);
6635
- const login = (await provider.identity())?.login;
6636
- if (access2 === "member")
6637
- ui.line(
6638
- "member",
6639
- `${org}${login === void 0 ? "" : ` (as ${login})`}`,
6640
- `You are a member of ${org}${login === void 0 ? "" : ` (as ${login})`}`
6641
- );
6642
- else if (access2 === "restricted") {
6643
- ui.warn(
6644
- `${org} restricts OAuth apps, so its private repositories are hidden from rness`
6645
- );
6646
- ui.hint(`ask an owner to approve it: ${revokeUrl()}`, "stderr");
6647
- }
6648
- }
6649
6835
  let listed = [];
6650
6836
  let failed = false;
6651
6837
  if (!ui.session) ui.line("listing", `${org} repositories\u2026`);
@@ -6661,7 +6847,7 @@ async function pickRepositories(input) {
6661
6847
  // list below it must agree (spec 0009).
6662
6848
  (l) => [
6663
6849
  "listed",
6664
- `${l.repositories.filter(isMemberRepo).length} repositories of ${org}`
6850
+ `${count(l.repositories.filter(isMemberRepo).length, ["repository", "repositories"])} of ${org}`
6665
6851
  ]
6666
6852
  );
6667
6853
  listed = listing.repositories;
@@ -6795,17 +6981,6 @@ function blankConflict(opts) {
6795
6981
  if (opts.provider !== void 0) return "--provider";
6796
6982
  return null;
6797
6983
  }
6798
- async function chooseStart(prompts) {
6799
- const answer = await prompts.select({
6800
- message: "How do you want to start?",
6801
- options: [
6802
- { value: "github", label: "From a GitHub organization" },
6803
- { value: "blank", label: "Blank local workspace" }
6804
- ]
6805
- });
6806
- if (prompts.isCancel(answer)) return null;
6807
- return answer === "blank" ? "blank" : "github";
6808
- }
6809
6984
  function providerFlagError(value) {
6810
6985
  if (!isProviderName(value))
6811
6986
  return `unknown provider "${value}" (${Object.keys(PROVIDERS).join(", ")})`;
@@ -6814,27 +6989,37 @@ function providerFlagError(value) {
6814
6989
  return `provider "${value}" is not available yet (available: ${available.join(", ")})`;
6815
6990
  }
6816
6991
  var OTHER = /* @__PURE__ */ Symbol("another organization");
6992
+ var OTHER_LABEL = "an organization not listed here\u2026";
6993
+ var OTHER_HINT = "not a member, or it has not approved rness yet";
6994
+ var NOT_APPROVED = "rness not approved";
6817
6995
  async function chooseOrganization(provider, prompts) {
6818
6996
  if (!provider.authenticated) return OTHER;
6819
- let names;
6997
+ let organizations;
6998
+ let self;
6820
6999
  try {
6821
- const self = (await provider.identity())?.login;
6822
- names = (await provider.listOrganizations()).map((o) => o.login);
6823
- if (self !== void 0) names.push(self);
7000
+ self = (await provider.identity())?.login;
7001
+ organizations = await provider.listOrganizations();
6824
7002
  } catch {
6825
7003
  return OTHER;
6826
7004
  }
6827
- if (names.length === 0) return OTHER;
7005
+ if (organizations.length === 0 && self === void 0) return OTHER;
7006
+ const padlock = unicode();
7007
+ const options = organizations.map((o) => ({
7008
+ value: o.login,
7009
+ label: !o.approved && padlock ? `${o.login} ${PRIVATE_MARK}` : o.login,
7010
+ ...o.approved ? {} : { hint: NOT_APPROVED }
7011
+ }));
7012
+ if (self !== void 0)
7013
+ options.push({ value: self, label: self, hint: "your account" });
7014
+ options.push({ value: "", label: OTHER_LABEL, hint: OTHER_HINT });
6828
7015
  const answer = await prompts.select({
6829
7016
  message: "Which GitHub organization?",
6830
- options: [
6831
- ...names.map((name) => ({ value: name, label: name })),
6832
- { value: "", label: "another one\u2026" }
6833
- ]
7017
+ options
6834
7018
  });
6835
7019
  if (prompts.isCancel(answer)) return null;
6836
7020
  if (typeof answer !== "string" || answer === "") return OTHER;
6837
- return answer;
7021
+ const picked = organizations.find((o) => o.login === answer);
7022
+ return { org: answer, canGrant: picked?.canGrant ?? null };
6838
7023
  }
6839
7024
  async function publishContext(input) {
6840
7025
  const { ui, prompts, provider, org } = input;
@@ -6873,6 +7058,11 @@ async function publishContext(input) {
6873
7058
  async function createCommand(opts, deps = {}) {
6874
7059
  const terminal = deps.terminal ?? defaultTerminal;
6875
7060
  const transport = deps.transport ?? defaultTransport;
7061
+ const clock = {
7062
+ ...deps.open === void 0 ? {} : { open: deps.open },
7063
+ ...deps.sleep === void 0 ? {} : { sleep: deps.sleep },
7064
+ ...deps.now === void 0 ? {} : { now: deps.now }
7065
+ };
6876
7066
  let provider = deps.provider;
6877
7067
  const getProvider = async () => provider ??= await githubProvider(opts.githubApi);
6878
7068
  const cwd = opts.cwd ?? process.cwd();
@@ -6938,6 +7128,7 @@ async function createCommand(opts, deps = {}) {
6938
7128
  () => ui.line(...CHECKING)
6939
7129
  );
6940
7130
  if (sshTest.unattended) ui.gitIsSilent = true;
7131
+ else if (sshTest.access.ok) ui.hint(passphraseHint(), "stderr");
6941
7132
  }
6942
7133
  return sshTest.access;
6943
7134
  };
@@ -6967,10 +7158,14 @@ async function createCommand(opts, deps = {}) {
6967
7158
  return 1;
6968
7159
  }
6969
7160
  let blank = opts.blank === true;
6970
- if (!blank && interactive && opts.org === void 0 && opts.repos === void 0) {
6971
- const start = await chooseStart(await prompts());
6972
- if (start === null) return cancelled(ui);
6973
- blank = start === "blank";
7161
+ let chosenProvider = "github";
7162
+ if (opts.provider !== void 0 && isProviderName(opts.provider))
7163
+ chosenProvider = opts.provider;
7164
+ else if (!blank && interactive && opts.org === void 0 && opts.repos === void 0) {
7165
+ const answer = await askProvider(await prompts());
7166
+ if (typeof answer !== "string") return cancelled(ui);
7167
+ if (answer === BLANK) blank = true;
7168
+ else chosenProvider = answer;
6974
7169
  }
6975
7170
  if (blank)
6976
7171
  return await createBlank({
@@ -6984,14 +7179,6 @@ async function createCommand(opts, deps = {}) {
6984
7179
  ui,
6985
7180
  prompts
6986
7181
  });
6987
- let chosenProvider = "github";
6988
- if (opts.provider !== void 0 && isProviderName(opts.provider))
6989
- chosenProvider = opts.provider;
6990
- else if (interactive) {
6991
- const answer = await askProvider(await prompts());
6992
- if (typeof answer !== "string") return cancelled(ui);
6993
- chosenProvider = answer;
6994
- }
6995
7182
  let prompted = false;
6996
7183
  let org = opts.org;
6997
7184
  if (org === void 0 && !interactive) {
@@ -7001,19 +7188,20 @@ async function createCommand(opts, deps = {}) {
7001
7188
  if (interactive && opts.repos === void 0 && !(await getProvider()).authenticated) {
7002
7189
  const p = await prompts();
7003
7190
  const login = await p.confirm({
7004
- message: "Log in to GitHub to list private repositories?",
7191
+ message: "Log in to GitHub to list your organizations and private repositories?",
7005
7192
  initialValue: true
7006
7193
  });
7007
7194
  if (p.isCancel(login)) return cancelled(ui);
7008
7195
  if (login === true) {
7009
7196
  const code2 = await loginCommand(
7010
7197
  opts.githubApi === void 0 ? {} : { githubApi: opts.githubApi },
7011
- { terminal, ui }
7198
+ { terminal, ui, ...clock }
7012
7199
  );
7013
7200
  if (code2 !== 0) return code2;
7014
7201
  provider = await githubProvider(opts.githubApi);
7015
7202
  }
7016
7203
  }
7204
+ let canGrant = null;
7017
7205
  if (org === void 0) {
7018
7206
  const chosen = await chooseOrganization(
7019
7207
  await getProvider(),
@@ -7021,7 +7209,8 @@ async function createCommand(opts, deps = {}) {
7021
7209
  );
7022
7210
  if (chosen === null) return cancelled(ui);
7023
7211
  if (chosen !== OTHER) {
7024
- org = chosen;
7212
+ org = chosen.org;
7213
+ canGrant = chosen.canGrant;
7025
7214
  prompted = true;
7026
7215
  }
7027
7216
  }
@@ -7053,6 +7242,16 @@ async function createCommand(opts, deps = {}) {
7053
7242
  );
7054
7243
  return 2;
7055
7244
  }
7245
+ const outcome = await checkAccess({
7246
+ org,
7247
+ provider: await getProvider(),
7248
+ ui,
7249
+ interactive,
7250
+ prompts,
7251
+ canGrant,
7252
+ deps: clock
7253
+ });
7254
+ if (outcome === "cancelled") return cancelled(ui);
7056
7255
  const host = await chooseHost();
7057
7256
  const contextUrl = repoUrl(host, org, ".rness");
7058
7257
  let gitProvider = await getProvider();
@@ -7292,7 +7491,7 @@ ${next2.map((l) => ` ${l}`).join("\n")}
7292
7491
  return reportError(e);
7293
7492
  } finally {
7294
7493
  if (staging !== void 0)
7295
- await rm6(staging, { recursive: true, force: true }).catch(() => void 0);
7494
+ await rm7(staging, { recursive: true, force: true }).catch(() => void 0);
7296
7495
  }
7297
7496
  }
7298
7497
 
@@ -7397,7 +7596,7 @@ async function logoutCommand() {
7397
7596
  `${status("updated", "git: rness no longer answers for https://github.com")}
7398
7597
  `
7399
7598
  );
7400
- process.stdout.write(`revoke rness on GitHub: ${revokeUrl()}
7599
+ process.stdout.write(`revoke rness on GitHub: ${approvalUrl()}
7401
7600
  `);
7402
7601
  return 0;
7403
7602
  } catch (e) {
@@ -7407,7 +7606,7 @@ async function logoutCommand() {
7407
7606
 
7408
7607
  // src/commands/upgrade.ts
7409
7608
  import { execFile as execFile6 } from "child_process";
7410
- import { mkdtemp as mkdtemp3, readFile as readFile10, rm as rm7 } from "fs/promises";
7609
+ import { mkdtemp as mkdtemp3, readFile as readFile10, rm as rm8 } from "fs/promises";
7411
7610
  import { tmpdir as tmpdir3 } from "os";
7412
7611
  import { basename as basename5, join as join24, relative as relative5 } from "path";
7413
7612
  import { promisify as promisify6 } from "util";
@@ -7433,7 +7632,7 @@ async function scaffoldFor(version, rnessDir) {
7433
7632
  if (installed?.version === version && inside2 !== null && await exists(join24(inside2, "package.json")))
7434
7633
  return { dir: inside2 };
7435
7634
  const scratch = await mkdtemp3(join24(tmpdir3(), "rness-pack-"));
7436
- const cleanup = () => rm7(scratch, { recursive: true, force: true });
7635
+ const cleanup = () => rm8(scratch, { recursive: true, force: true });
7437
7636
  try {
7438
7637
  const { stdout } = await execFileP6(
7439
7638
  "npm",
@@ -7852,6 +8051,102 @@ async function validateCommand(opts, deps = {}) {
7852
8051
  // src/cli.ts
7853
8052
  import { Command, CommanderError, Option } from "commander";
7854
8053
 
8054
+ // src/commands/doc.ts
8055
+ import { mkdir as mkdir7, readFile as readFile11, writeFile as writeFile4 } from "fs/promises";
8056
+ import { join as join25 } from "path";
8057
+ var NUMBERED = Object.keys(STATUSES);
8058
+ var SECTIONS = {
8059
+ specs: [
8060
+ "Summary",
8061
+ "Scope",
8062
+ "Out of scope",
8063
+ "Alternatives considered",
8064
+ "To verify",
8065
+ "Tests"
8066
+ ],
8067
+ plans: ["Decided here, where the specification leaves it open", "Tasks"]
8068
+ };
8069
+ function slugOf(title) {
8070
+ const slug = title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "");
8071
+ return slug === "" ? "untitled" : slug;
8072
+ }
8073
+ function today() {
8074
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
8075
+ }
8076
+ function headingsOf(template) {
8077
+ return [...template.matchAll(/^## (.+)$/gm)].map((m) => (m[1] ?? "").trim());
8078
+ }
8079
+ async function adrSections(rnessDir) {
8080
+ const own = join25(rnessDir, "adr", "0000-template.md");
8081
+ const shipped = join25(scaffoldDir(), "adr", "0000-template.md");
8082
+ for (const file of [own, shipped]) {
8083
+ try {
8084
+ const sections = headingsOf(await readFile11(file, "utf8"));
8085
+ if (sections.length > 0) return sections;
8086
+ } catch {
8087
+ }
8088
+ }
8089
+ return ["Context", "Decision", "Alternatives Considered", "Consequences"];
8090
+ }
8091
+ async function writeDocument(file, text) {
8092
+ try {
8093
+ await writeFile4(file, text, { flag: "wx" });
8094
+ } catch (e) {
8095
+ if (e.code !== "EEXIST") throw e;
8096
+ process.stderr.write(`${file} exists; nothing written
8097
+ `);
8098
+ return 1;
8099
+ }
8100
+ process.stdout.write(`${file}
8101
+ `);
8102
+ return 0;
8103
+ }
8104
+ async function docNewCommand(collection, opts) {
8105
+ if (!NUMBERED.includes(collection)) {
8106
+ process.stderr.write(
8107
+ `${collection} is not a numbered collection of this workspace (${NUMBERED.join(", ")})
8108
+ `
8109
+ );
8110
+ return 2;
8111
+ }
8112
+ const name = collection;
8113
+ try {
8114
+ const ws = await findWorkspace(opts.cwd ?? process.cwd());
8115
+ const dir = join25(ws.rnessDir, name);
8116
+ await mkdir7(dir, { recursive: true });
8117
+ let highest = 0;
8118
+ for (const item of await collectMarkdown(dir)) {
8119
+ const number2 = documentNumber(item.rel);
8120
+ if (number2 !== null) highest = Math.max(highest, Number(number2));
8121
+ }
8122
+ const number = String(highest + 1).padStart(4, "0");
8123
+ const title = opts.title?.trim() || "Untitled";
8124
+ const file = join25(dir, `${number}-${slugOf(title)}.md`);
8125
+ const status2 = STATUSES[name]?.[0] ?? "Draft";
8126
+ const date = today();
8127
+ const front = [
8128
+ "---",
8129
+ `date: ${date}`,
8130
+ `status: ${status2}`,
8131
+ "repo: ''",
8132
+ // An ADR is never updated once accepted: no `updated` (CONVENTIONS.md).
8133
+ ...name === "adr" ? [] : [`updated: ${date}`],
8134
+ "---"
8135
+ ];
8136
+ const sections = name === "adr" ? await adrSections(ws.rnessDir) : SECTIONS[name] ?? [];
8137
+ const body = [
8138
+ `# ${number} \u2014 ${title}`,
8139
+ // The rule under the title, as the ADR and specification templates draw it.
8140
+ ...name === "plans" ? [] : ["", "---"],
8141
+ ...sections.flatMap((s) => ["", `## ${s}`])
8142
+ ];
8143
+ return await writeDocument(file, `${[...front, "", ...body].join("\n")}
8144
+ `);
8145
+ } catch (e) {
8146
+ return reportError(e);
8147
+ }
8148
+ }
8149
+
7855
8150
  // src/commands/git-credential.ts
7856
8151
  async function readStdin() {
7857
8152
  const chunks = [];
@@ -7880,8 +8175,8 @@ import { realpath as realpath4 } from "fs/promises";
7880
8175
  import { isAbsolute as isAbsolute2, posix as posix8, relative as relative7, resolve as resolve7, sep as sep5 } from "path";
7881
8176
 
7882
8177
  // src/mcp/tools.ts
7883
- import { readFile as readFile11, realpath as realpath3, stat as stat4 } from "fs/promises";
7884
- import { isAbsolute, join as join25, posix as posix7, relative as relative6, resolve as resolve6, sep as sep4 } from "path";
8178
+ import { readFile as readFile12, realpath as realpath3, stat as stat4 } from "fs/promises";
8179
+ import { isAbsolute, join as join26, posix as posix7, relative as relative6, resolve as resolve6, sep as sep4 } from "path";
7885
8180
  var LABELS = {
7886
8181
  standards: "Standards",
7887
8182
  adr: "Decisions",
@@ -7910,7 +8205,7 @@ async function documents(rnessDir, only) {
7910
8205
  const docs = [];
7911
8206
  for (const collection of COLLECTIONS) {
7912
8207
  if (only !== void 0 && collection !== only) continue;
7913
- for (const item of await collectMarkdown(join25(rnessDir, collection))) {
8208
+ for (const item of await collectMarkdown(join26(rnessDir, collection))) {
7914
8209
  const path = `${collection}/${item.rel}`;
7915
8210
  if (path === TEMPLATE2) continue;
7916
8211
  const status2 = item.fields?.["status"];
@@ -8032,7 +8327,7 @@ async function read(cwd, args) {
8032
8327
  return refuse(
8033
8328
  `${path}: ${info.size} bytes, over the 256 KiB rness_read returns`
8034
8329
  );
8035
- return { text: await readFile11(real, "utf8") };
8330
+ return { text: await readFile12(real, "utf8") };
8036
8331
  }
8037
8332
  async function search(cwd, args) {
8038
8333
  const { ws } = await workspaceAt(cwd);
@@ -8140,8 +8435,8 @@ function mcpTools(cwd) {
8140
8435
 
8141
8436
  // src/pulse/detached.ts
8142
8437
  import { spawn as nodeSpawn } from "child_process";
8143
- import { mkdir as mkdir7, open as open2, readFile as readFile12, rm as rm8, stat as stat5, writeFile as writeFile4 } from "fs/promises";
8144
- import { join as join26 } from "path";
8438
+ import { mkdir as mkdir8, open as open2, readFile as readFile13, rm as rm9, stat as stat5, writeFile as writeFile5 } from "fs/promises";
8439
+ import { join as join27 } from "path";
8145
8440
  import { setTimeout as delay2 } from "timers/promises";
8146
8441
  var detached = (args, cwd) => {
8147
8442
  const bin = process.argv[1];
@@ -8155,11 +8450,11 @@ var detached = (args, cwd) => {
8155
8450
  child.on("error", () => void 0);
8156
8451
  child.unref();
8157
8452
  };
8158
- var failureFile = () => join26(configDir(), "pulse.json");
8453
+ var failureFile = () => join27(configDir(), "pulse.json");
8159
8454
  async function recordFailure(reason3) {
8160
8455
  try {
8161
- await mkdir7(configDir(), { recursive: true, mode: 448 });
8162
- await writeFile4(
8456
+ await mkdir8(configDir(), { recursive: true, mode: 448 });
8457
+ await writeFile5(
8163
8458
  failureFile(),
8164
8459
  `${JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), reason: reason3 })}
8165
8460
  `
@@ -8169,8 +8464,8 @@ async function recordFailure(reason3) {
8169
8464
  }
8170
8465
  async function takeFailure() {
8171
8466
  try {
8172
- const data = JSON.parse(await readFile12(failureFile(), "utf8"));
8173
- await rm8(failureFile(), { force: true });
8467
+ const data = JSON.parse(await readFile13(failureFile(), "utf8"));
8468
+ await rm9(failureFile(), { force: true });
8174
8469
  const reason3 = data !== null && typeof data === "object" ? data["reason"] : void 0;
8175
8470
  return typeof reason3 === "string" && reason3 !== "" ? reason3 : null;
8176
8471
  } catch {
@@ -8179,8 +8474,8 @@ async function takeFailure() {
8179
8474
  }
8180
8475
  var PULSE_LOCK_STALE_MS = 15 * 6e4;
8181
8476
  async function oneSyncAtATime(key, work, options = {}) {
8182
- await mkdir7(configDir(), { recursive: true, mode: 448 });
8183
- const file = join26(configDir(), `pulse-${key}.lock`);
8477
+ await mkdir8(configDir(), { recursive: true, mode: 448 });
8478
+ const file = join27(configDir(), `pulse-${key}.lock`);
8184
8479
  for (; ; ) {
8185
8480
  try {
8186
8481
  const handle = await open2(file, "wx");
@@ -8192,7 +8487,7 @@ async function oneSyncAtATime(key, work, options = {}) {
8192
8487
  (s) => Date.now() - s.mtimeMs,
8193
8488
  () => 0
8194
8489
  );
8195
- if (age > PULSE_LOCK_STALE_MS) await rm8(file, { force: true });
8490
+ if (age > PULSE_LOCK_STALE_MS) await rm9(file, { force: true });
8196
8491
  else if (options.wait !== true) return false;
8197
8492
  else await (options.sleep ?? ((ms) => delay2(ms)))(1e3);
8198
8493
  }
@@ -8201,7 +8496,7 @@ async function oneSyncAtATime(key, work, options = {}) {
8201
8496
  await work();
8202
8497
  return true;
8203
8498
  } finally {
8204
- await rm8(file, { force: true });
8499
+ await rm9(file, { force: true });
8205
8500
  }
8206
8501
  }
8207
8502
 
@@ -8232,9 +8527,6 @@ function cwdOf(input, env) {
8232
8527
  return env["CLAUDE_PROJECT_DIR"] ?? process.cwd();
8233
8528
  }
8234
8529
  var toPosix2 = (p) => p.split(sep5).join(posix8.sep);
8235
- function count(n, [one, many]) {
8236
- return `${n} ${n === 1 ? one : many}`;
8237
- }
8238
8530
  async function safetyNet(ws, manifest) {
8239
8531
  const notes = [];
8240
8532
  const drift = await pinDrift(ws.rnessDir);
@@ -8576,12 +8868,12 @@ async function mcpCommand(opts, io = {
8576
8868
  }
8577
8869
 
8578
8870
  // src/commands/pulse.ts
8579
- import { readFile as readFile14, readdir as readdir5 } from "fs/promises";
8580
- import { join as join27 } from "path";
8871
+ import { readFile as readFile15, readdir as readdir5 } from "fs/promises";
8872
+ import { join as join28 } from "path";
8581
8873
  import { setTimeout as delay3 } from "timers/promises";
8582
8874
 
8583
8875
  // src/pulse/opened.ts
8584
- import { mkdir as mkdir8, readFile as readFile13, rename as rename3, rm as rm9, writeFile as writeFile5 } from "fs/promises";
8876
+ import { mkdir as mkdir9, readFile as readFile14, rename as rename3, rm as rm10, writeFile as writeFile6 } from "fs/promises";
8585
8877
  import { dirname as dirname9 } from "path";
8586
8878
  var NAME2 = "rness/opened";
8587
8879
  function openedFile(rnessDir) {
@@ -8593,7 +8885,7 @@ async function readOpened(file) {
8593
8885
  if (file === null) return opened;
8594
8886
  let data;
8595
8887
  try {
8596
- data = JSON.parse(await readFile13(file, "utf8"));
8888
+ data = JSON.parse(await readFile14(file, "utf8"));
8597
8889
  } catch {
8598
8890
  return opened;
8599
8891
  }
@@ -8608,13 +8900,13 @@ async function writeOpened(file, opened) {
8608
8900
  if (file === null) return;
8609
8901
  try {
8610
8902
  if (opened.size === 0) {
8611
- await rm9(file, { force: true });
8903
+ await rm10(file, { force: true });
8612
8904
  return;
8613
8905
  }
8614
8906
  const entries = [...opened].sort(([a], [b]) => a - b);
8615
- await mkdir8(dirname9(file), { recursive: true });
8907
+ await mkdir9(dirname9(file), { recursive: true });
8616
8908
  const temporary = `${file}.${process.pid}`;
8617
- await writeFile5(
8909
+ await writeFile6(
8618
8910
  temporary,
8619
8911
  `${JSON.stringify(Object.fromEntries(entries), null, 2)}
8620
8912
  `
@@ -8708,7 +9000,7 @@ async function scopeOf(c, name, tabs, owners) {
8708
9000
  label: labelOf(name),
8709
9001
  rows: []
8710
9002
  };
8711
- const dir = join27(c.rnessDir, name);
9003
+ const dir = join28(c.rnessDir, name);
8712
9004
  const shape = await readShape(c.rnessDir, name);
8713
9005
  const fronts = new Map(
8714
9006
  (await collectMarkdown(dir)).map((item) => [
@@ -8754,7 +9046,7 @@ async function working(ui, doing, count3, work) {
8754
9046
  async function filesOf2(rnessDir) {
8755
9047
  const files = /* @__PURE__ */ new Set();
8756
9048
  const walk = async (rel) => {
8757
- const dir = rel === "" ? rnessDir : join27(rnessDir, ...rel.split("/"));
9049
+ const dir = rel === "" ? rnessDir : join28(rnessDir, ...rel.split("/"));
8758
9050
  for (const e of await readdir5(dir, { withFileTypes: true })) {
8759
9051
  const path = rel === "" ? e.name : `${rel}/${e.name}`;
8760
9052
  if (e.isDirectory()) {
@@ -8866,7 +9158,7 @@ async function syncBoard(c, board, ui, sayLayout, scope) {
8866
9158
  w.path,
8867
9159
  issueBody({
8868
9160
  path: w.path,
8869
- text: await readFile14(join27(c.rnessDir, ...w.path.split("/")), "utf8"),
9161
+ text: await readFile15(join28(c.rnessDir, ...w.path.split("/")), "utf8"),
8870
9162
  org: c.org,
8871
9163
  numbers,
8872
9164
  files
@@ -9358,6 +9650,14 @@ function buildProgram(state) {
9358
9650
  ).argument("[tab]", "open on this tab: adr, specs, plans, \u2026").addOption(new Option("--cwd <dir>").hideHelp()).action(async (tab, opts) => {
9359
9651
  state.code = await statusCommand(tab, opts);
9360
9652
  });
9653
+ const doc = program.command("doc").description("Documents of the workspace: doc new <collection>");
9654
+ doc.command("new").description(
9655
+ "Write the next numbered document of a collection (adr, specs, plans) and print its path"
9656
+ ).argument("<collection>", "adr, specs or plans").option("--title <text>", "the title; the file name takes its slug").addOption(new Option("--cwd <dir>").hideHelp()).action(
9657
+ async (collection, opts) => {
9658
+ state.code = await docNewCommand(collection, opts);
9659
+ }
9660
+ );
9361
9661
  program.command("validate").description(
9362
9662
  "Check the .rness/ tree against the contract and every generated block"
9363
9663
  ).addOption(new Option("--cwd <dir>").hideHelp()).action(async (opts) => {
@@ -9618,4 +9918,4 @@ export {
9618
9918
  OWN_COMMANDS,
9619
9919
  catchUp
9620
9920
  };
9621
- //# sourceMappingURL=chunk-MMPMXYZU.js.map
9921
+ //# sourceMappingURL=chunk-JHU2G7K7.js.map