@enrichlayer/el-linear 1.43.1 → 1.44.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.
package/README.md CHANGED
@@ -145,6 +145,33 @@ EL_LINEAR_PROFILE=forage el-linear teams list
145
145
  el-linear profile remove old-profile
146
146
  ```
147
147
 
148
+ ### Recommended for multiple workspaces: pin the repo
149
+
150
+ If you work across several workspaces, **pin each repo to the profile it belongs to** instead of relying on `profile use`. A pin is scoped to the repo, so a write in that repo always targets the right workspace no matter what the machine-global default happens to be.
151
+
152
+ ```bash
153
+ # In a repo, pin it to the profile it belongs to (defaults to the active one):
154
+ el-linear profile pin forage
155
+
156
+ # Show the resolved pin + where it came from:
157
+ el-linear profile pin --show
158
+
159
+ # Pin this exact origin owner/repo in your global user config instead:
160
+ el-linear profile pin work --global
161
+
162
+ # Remove the repo-local pin:
163
+ el-linear profile unpin
164
+
165
+ # Remove this repo's exact owner/repo entry from the global user config:
166
+ el-linear profile unpin --global
167
+ ```
168
+
169
+ A repo-local pin is stored in `.el-git.json` at the repo root as `{ "linearProfile": "<name>" }`. el-linear, el-git, and el-session read the same file and resolve the repo to the same workspace.
170
+
171
+ `--global` writes the exact origin `owner/repo` entry into `~/.config/el-git/linear-profiles.json`; the resolver also honors manually configured `owner/*` wildcard entries. The repo-local file wins over the global table.
172
+
173
+ When a write command (`issues create` / `comments create`) runs under the bare machine-global default in a repo with no effective pin, el-linear prints a one-line, non-blocking stderr hint suggesting `profile pin`. Silence it with `EL_LINEAR_NO_PIN_HINT=1` (or `--quiet`).
174
+
148
175
  Fix or clean up a single member's aliases/handles without hand-editing
149
176
  `config.json` — `profile members` operates directly on the active profile's
150
177
  on-disk config (no Linear API round-trip):
@@ -164,8 +191,11 @@ The active profile is selected by, in priority:
164
191
 
165
192
  1. `--profile <name>` flag (per-invocation)
166
193
  2. `EL_LINEAR_PROFILE` env var
167
- 3. `~/.config/el-linear/active-profile` (one-line marker, written by `profile use`)
168
- 4. Legacy single-file layout (`~/.config/el-linear/{token,config.json}`)
194
+ 3. **Repo pin** — `.el-git.json` at the repo root, else the `owner/repo` /
195
+ `owner/*` entry in `~/.config/el-git/linear-profiles.json` (see
196
+ [Recommended for multiple workspaces](#recommended-for-multiple-workspaces-pin-the-repo))
197
+ 4. `~/.config/el-linear/active-profile` (one-line marker, written by `profile use`)
198
+ 5. Legacy single-file layout (`~/.config/el-linear/{token,config.json}`)
169
199
 
170
200
  The legacy fallback means **existing single-profile users see no
171
201
  behavior change** — profiles are purely opt-in.
@@ -180,7 +210,10 @@ flag and env var are per-invocation and never touch the marker file, so they
180
210
  compose safely with any number of concurrent sessions each pinned to their
181
211
  own workspace. Reserve `profile use` for a human's own interactive default
182
212
  switch; `profile use`/`profile add` print a loud warning when they change the
183
- global marker so the blast radius is visible instead of silent.
213
+ global marker so the blast radius is visible instead of silent. Better still
214
+ for a human who works across workspaces: **`profile pin` each repo** (above) —
215
+ the pin sits above the global marker in the precedence list, so the right
216
+ workspace is selected automatically per-repo without any per-command flag.
184
217
 
185
218
  ## Migrating from v1.0–1.3
186
219
 
@@ -731,6 +764,15 @@ el-linear comments list DEV-123 --body
731
764
  # Full comment body...
732
765
  ```
733
766
 
767
+ Both `--body` surfaces treat "no text to print" as a failure rather than an
768
+ empty success, so a caller never has to guess whether silence meant "nothing is
769
+ there" or "the fetch silently dropped it": `comments read --body` exits 1 when
770
+ the comment has no body, and `comments list --body` exits 1 with
771
+ `el-linear: DEV-123 has no comments` (or `… has N comments, none with a body`)
772
+ and writes nothing to stdout. The structured surfaces need no such signal and
773
+ stay exit 0 — `--format summary` prints `(no results)` and the JSON envelope
774
+ carries `meta.count`.
775
+
734
776
  ### One-line write confirmations: `-q, --quiet`
735
777
 
736
778
  `issues create|update`, `issues relate`, and `comments create|update` accept
@@ -121,6 +121,14 @@ el-linear comments list DEV-123 --format json 2>&1 | python3 -c "import json,sys
121
121
  `#comment-<hash>`. `comments list --format summary` includes each comment id
122
122
  so you can copy it straight into `comments read`.
123
123
 
124
+ Empty output from a `--body` surface is never a silent success. `comments list
125
+ --body` exits 1 with `el-linear: DEV-123 has no comments` on stderr when the
126
+ issue has none (and `… has N comments, none with a body` when every body came
127
+ back blank), matching `comments read --body`'s guard — so "no comments" stays
128
+ distinguishable from "the fetch silently dropped the list". `--format summary`
129
+ (`(no results)`) and the JSON envelope (`meta.count`) report emptiness in band
130
+ and stay exit 0.
131
+
124
132
  ### Attachment reads and downloads
125
133
 
126
134
  List attachments first, then use the attachment ID or exact title. Text files
@@ -177,6 +185,14 @@ Every issue should communicate **why** the work matters and **what** success loo
177
185
  ### Example
178
186
 
179
187
  ```markdown
188
+ ## Intake decision
189
+ - Needed: Yes — contact tracking is split across spreadsheets, Linear, and memory.
190
+ - Worth doing: Yes — outbound scale makes the fragmentation costly now, not later.
191
+ - Existing work: searched "CRM", "contact tracking" with --include-closed; no duplicate.
192
+ - Owner: Nico (operations tooling).
193
+ - Placement: OPS / Sales infrastructure; self-hosted on the Hetzner box.
194
+ - Decision: PROCEED
195
+
180
196
  ## Set up a self-hosted CRM
181
197
 
182
198
  The team needs a CRM to replace fragmented contact tracking
@@ -195,6 +211,26 @@ and outreach tracked in one place.
195
211
 
196
212
  ---
197
213
 
214
+ ## Intake Decision Block (MANDATORY, blocking)
215
+
216
+ **`el-linear issues create` refuses any description without a `## Intake decision` section.** It must open the description, and the six lines must appear in this order:
217
+
218
+ ```markdown
219
+ ## Intake decision
220
+ - Needed: Yes — <why this is needed>
221
+ - Worth doing: Yes — <why the value exceeds the cost>
222
+ - Existing work: <duplicate/search result and evidence>
223
+ - Owner: <canonical owner or source of truth>
224
+ - Placement: <team/project/repository/document path>
225
+ - Decision: PROCEED
226
+ ```
227
+
228
+ Write it **before** composing the rest of the body. The gate runs before the create POST, so omitting it fails the call outright — nothing is written, and a finished issue body then has to be reassembled around a section you were never told to include.
229
+
230
+ The point is that the decision is *recorded*, not merely reached: `Existing work` cites the search you actually ran, and `Owner`/`Placement` name a concrete destination rather than a plausible one. It is the same discipline the two gates below enforce mechanically, applied to the judgment they cannot check.
231
+
232
+ Escape hatch: `--allow-missing-intake-decision`, which is recorded. It exists for an **accountable human** who has approved an exceptional create — not for an agent that would rather not write the block. `--skip-validation` also bypasses it but disables every other field check too, so prefer the narrow flag.
233
+
198
234
  ## Duplicate & Related Issues Check (MANDATORY)
199
235
 
200
236
  **Search before creating. No exceptions.**
@@ -366,6 +402,7 @@ Don't start implementation work on an unassigned issue — the assignee is the p
366
402
 
367
403
  Complete ALL items before creating any issue:
368
404
 
405
+ - [ ] **Intake decision** — description opens with the `## Intake decision` block (above). Blocking.
369
406
  - [ ] **Duplicate & related check** — searched for existing issues, linked related ones (above).
370
407
  - [ ] **Team** — ask user if unclear (`el-linear teams list`).
371
408
  - [ ] **Assignee** — ask user if unclear (`el-linear users list --active`).
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { maybeEmitPinMismatchHint } from "../config/pin-hint.js";
2
3
  import { resolveUserDisplayName } from "../config/resolver.js";
3
4
  import { CREATE_COMMENT_MUTATION, DELETE_COMMENT_MUTATION, GET_COMMENT_QUERY, LIST_COMMENTS_QUERY, UPDATE_COMMENT_MUTATION, } from "../queries/comments.js";
4
5
  import { autoLinkReferences, } from "../utils/auto-link-references.js";
@@ -134,6 +135,44 @@ function formatCommentBodyBlocks(comments) {
134
135
  })
135
136
  .join("\n\n---\n\n");
136
137
  }
138
+ /**
139
+ * Why the `--body` list surface has nothing to print, or `null` when it does.
140
+ *
141
+ * Split out from the printer so the two "no text" cases stay distinguishable in
142
+ * the message: zero comments is a fact about the issue, while comments that all
143
+ * carry empty bodies is the shape a partially-dropped payload takes.
144
+ */
145
+ function describeMissingCommentBodies(comments) {
146
+ if (comments.length === 0) {
147
+ return "has no comments";
148
+ }
149
+ const hasBody = comments.some((comment) => typeof comment.body === "string" && comment.body.trim() !== "");
150
+ if (!hasBody) {
151
+ return `has ${comments.length} comment${comments.length === 1 ? "" : "s"}, none with a body`;
152
+ }
153
+ return null;
154
+ }
155
+ /**
156
+ * List-surface sibling of `printRawCommentBody`. `--body` is a raw-text
157
+ * extraction contract, so writing a lone newline and exiting 0 would make "this
158
+ * issue has no comments" byte-identical to "the fetch or render silently
159
+ * dropped the list" — no caller could tell the two apart. Mirror the
160
+ * single-comment guard exactly: nothing on stdout, a stderr hint, exit 1, so
161
+ * scripts branch on the exit code (DEV-7160).
162
+ *
163
+ * The structured surfaces need no such guard — `--format summary` prints
164
+ * `(no results)` and the JSON envelope carries `meta.count`, both of which say
165
+ * "empty" in band.
166
+ */
167
+ function printRawCommentBodyBlocks(comments, label) {
168
+ const missing = describeMissingCommentBodies(comments);
169
+ if (missing === null) {
170
+ process.stdout.write(`${formatCommentBodyBlocks(comments)}\n`);
171
+ return;
172
+ }
173
+ process.stderr.write(`el-linear: ${label} ${missing}\n`);
174
+ process.exit(1);
175
+ }
137
176
  /**
138
177
  * Wrap valid issue references in a comment body as markdown links — same logic as for
139
178
  * issue descriptions. Returns the rewritten body plus the validated identifier→UUID map
@@ -186,6 +225,9 @@ async function autoLinkCommentReferences(args) {
186
225
  }
187
226
  async function handleCreateComment(issueId, options, command) {
188
227
  const rootOpts = getRootOpts(command);
228
+ // DEV-7277: non-blocking nudge when this comment is landing via the
229
+ // machine-global active profile in a repo that hasn't pinned a workspace.
230
+ maybeEmitPinMismatchHint({ hasProfileFlag: Boolean(rootOpts.profile) });
189
231
  const graphQLService = await createGraphQLService(rootOpts);
190
232
  const linearService = await createLinearService(rootOpts);
191
233
  // Apply messageFooter (config or --footer flag) before any further
@@ -358,7 +400,7 @@ async function handleListComments(issueId, options, command) {
358
400
  const fullBodySummary = options.truncate === false && getOutputFormat() === "summary";
359
401
  const nodes = result.issue.comments.nodes.map((comment) => transformComment(comment, { fullBodySummary }));
360
402
  if (options.body === true) {
361
- process.stdout.write(`${formatCommentBodyBlocks(nodes)}\n`);
403
+ printRawCommentBodyBlocks(nodes, result.issue.identifier);
362
404
  return;
363
405
  }
364
406
  outputSuccess({
@@ -4,6 +4,7 @@ import { enrichProjectResolverError, enrichValidationErrors, } from "../config/e
4
4
  import { evaluateGoalCompletion, formatGoalCompletionBlock, getGoalCompletionGateConfig, } from "../config/goal-completion-validation.js";
5
5
  import { evaluateIntakeDecision, formatIntakeDecisionBlock, getIntakeDecisionGateConfig, } from "../config/intake-decision-validation.js";
6
6
  import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
7
+ import { maybeEmitPinMismatchHint } from "../config/pin-hint.js";
7
8
  import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
8
9
  import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvableReferenceError, } from "../config/sop-label-validation.js";
9
10
  import { resolveDefaultStatus } from "../config/status-defaults.js";
@@ -812,6 +813,10 @@ async function enforceIntakeDecision(description, options) {
812
813
  }
813
814
  async function handleCreateIssue(title, options, command) {
814
815
  const rootOpts = getRootOpts(command);
816
+ // DEV-7277: non-blocking nudge when this write is landing via the
817
+ // machine-global active profile in a repo that hasn't pinned a workspace.
818
+ // stderr only; never blocks; suppressed by --quiet / EL_LINEAR_NO_PIN_HINT.
819
+ maybeEmitPinMismatchHint({ hasProfileFlag: Boolean(rootOpts.profile) });
815
820
  // DEV-5920 (cycle-2): resolve the description exactly ONCE, before anything
816
821
  // else. On create, resolveDescription would otherwise run three times —
817
822
  // field validation (inside resolveCreateInputs), the body build, and the
@@ -44,3 +44,23 @@ export declare function runProfileList(): Promise<ProfileListReport>;
44
44
  export declare function runProfileUse(name: string): Promise<void>;
45
45
  export declare function runProfileAdd(name: string): Promise<void>;
46
46
  export declare function runProfileRemove(name: string, force: boolean): Promise<void>;
47
+ export interface ProfilePinOptions {
48
+ global?: boolean;
49
+ show?: boolean;
50
+ }
51
+ export interface ProfileUnpinOptions {
52
+ global?: boolean;
53
+ }
54
+ /**
55
+ * `el-linear profile pin [name]` — pin the current git repo to a Linear
56
+ * profile. Default target is the repo-local `.el-git.json`; `--global` writes
57
+ * the `owner/repo` entry into the shared global table. `--show` reports the
58
+ * resolved pin without writing.
59
+ */
60
+ export declare function runProfilePin(name: string | undefined, opts: ProfilePinOptions): Promise<void>;
61
+ /**
62
+ * `el-linear profile unpin` — remove the repo-local pin, or the exact global
63
+ * owner/repo mapping with `--global`. Preserves sibling keys and wildcard
64
+ * mappings; deletes a config file only when the removed pin was its sole data.
65
+ */
66
+ export declare function runProfileUnpin(opts?: ProfileUnpinOptions): Promise<void>;
@@ -23,7 +23,9 @@
23
23
  import { promises as fsp } from "node:fs";
24
24
  import path from "node:path";
25
25
  import { confirm } from "@inquirer/prompts";
26
+ import { atomicWrite, withFileLock } from "../auth/oauth-fs.js";
26
27
  import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, isSafeProfileName as isSafeName, PROFILES_DIR, profilePaths, readActiveProfileMarker, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
28
+ import { applyGlobalPin, applyRepoLocalPin, defaultRepoProfileOps, globalPinTablePath, LINEAR_PROFILE_REPO_FILE, removeGlobalPin, removeRepoLocalPin, resolveRepoLinearProfile, } from "../config/repo-profile.js";
27
29
  import { outputSuccess, outputWarning } from "../utils/output.js";
28
30
  import { runFullWizard } from "./init/index.js";
29
31
  import { registerMembersCommands } from "./profile/members.js";
@@ -75,6 +77,23 @@ export function setupProfileCommands(program) {
75
77
  .action(async (name, opts) => {
76
78
  await runProfileRemove(name, opts.force === true);
77
79
  });
80
+ // `el-linear profile pin [name]` — DEV-7277: pin the current git repo to a
81
+ // Linear profile so multi-workspace users stop defaulting to the wrong one.
82
+ profile
83
+ .command("pin [name]")
84
+ .description("Pin the current git repo to a Linear profile so writes target the right workspace. Writes .el-git.json at the repo root (shared with el-git/el-session); [name] defaults to the active profile.")
85
+ .option("--global", "pin via the global owner/repo table (~/.config/el-git/linear-profiles.json) instead of the repo-local .el-git.json")
86
+ .option("--show", "print the resolved pin + its source without writing anything")
87
+ .action(async (name, opts) => {
88
+ await runProfilePin(name, opts);
89
+ });
90
+ profile
91
+ .command("unpin")
92
+ .description("Remove this repo's pin. Defaults to .el-git.json; --global removes its exact owner/repo mapping from the user-level table.")
93
+ .option("--global", "remove the exact origin owner/repo entry from ~/.config/el-git/linear-profiles.json")
94
+ .action(async (opts) => {
95
+ await runProfileUnpin(opts);
96
+ });
78
97
  // `el-linear profile migrate-legacy` — registered alongside add/list/etc.
79
98
  // Lives in its own module so the multi-step migration logic stays
80
99
  // self-contained and unit-testable without dragging in the full
@@ -188,12 +207,141 @@ export async function runProfileRemove(name, force) {
188
207
  await fsp.rm(dir, { recursive: true, force: true });
189
208
  // If the just-removed profile was the active one, clear the marker
190
209
  // so subsequent invocations fall back to the default paths.
191
- const active = resolveActiveProfile();
192
- if (active.name === trimmed && (await pathExists(ACTIVE_PROFILE_FILE))) {
210
+ const activeMarker = readActiveProfileMarker();
211
+ if (activeMarker === trimmed && (await pathExists(ACTIVE_PROFILE_FILE))) {
193
212
  await fsp.rm(ACTIVE_PROFILE_FILE, { force: true });
194
213
  }
195
214
  outputSuccess({ data: { removed: trimmed, dir } });
196
215
  }
216
+ async function readFileOrNull(p) {
217
+ try {
218
+ return await fsp.readFile(p, "utf8");
219
+ }
220
+ catch (err) {
221
+ if (err.code === "ENOENT")
222
+ return null;
223
+ throw err;
224
+ }
225
+ }
226
+ async function assertNotSymbolicLink(p) {
227
+ try {
228
+ if ((await fsp.lstat(p)).isSymbolicLink()) {
229
+ throw new Error(`Refusing to edit symbolic link ${p}. Replace it with a regular JSON file, then retry.`);
230
+ }
231
+ }
232
+ catch (err) {
233
+ if (err.code === "ENOENT")
234
+ return;
235
+ throw err;
236
+ }
237
+ }
238
+ /**
239
+ * `el-linear profile pin [name]` — pin the current git repo to a Linear
240
+ * profile. Default target is the repo-local `.el-git.json`; `--global` writes
241
+ * the `owner/repo` entry into the shared global table. `--show` reports the
242
+ * resolved pin without writing.
243
+ */
244
+ export async function runProfilePin(name, opts) {
245
+ const ops = defaultRepoProfileOps();
246
+ const cwd = process.cwd();
247
+ const root = ops.repoRoot(cwd);
248
+ if (opts.show) {
249
+ const resolved = resolveRepoLinearProfile(cwd);
250
+ outputSuccess({
251
+ data: {
252
+ pinned: resolved?.profile ?? null,
253
+ source: resolved?.source ?? null,
254
+ repoRoot: root,
255
+ originFullpath: ops.originFullpath(cwd),
256
+ },
257
+ });
258
+ return;
259
+ }
260
+ if (!root) {
261
+ throw new Error("Not in a git repository. `profile pin` writes a repo-scoped pin and needs a git repo (run it inside the repo you want to pin).");
262
+ }
263
+ // Default to the active profile when no name is given.
264
+ const target = (name ?? resolveActiveProfile().name ?? "").trim();
265
+ if (!target) {
266
+ throw new Error("No profile name given and no active profile is set (legacy single-profile default). Pass a name explicitly: `el-linear profile pin <name>`.");
267
+ }
268
+ if (!isSafeName(target)) {
269
+ throw new Error(`Profile name "${target}" must contain only [a-z0-9_-]. Pick a different name.`);
270
+ }
271
+ if (opts.global) {
272
+ const fullpath = ops.originFullpath(cwd);
273
+ if (!fullpath) {
274
+ throw new Error("Cannot determine the origin owner/repo for a global pin. Add an `origin` remote, or drop --global to write a repo-local .el-git.json instead.");
275
+ }
276
+ const file = globalPinTablePath();
277
+ await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
278
+ await withFileLock(file, async () => {
279
+ const next = applyGlobalPin(await readFileOrNull(file), fullpath, target);
280
+ await atomicWrite(file, next, 0o644);
281
+ });
282
+ outputSuccess({
283
+ data: { scope: "global", key: fullpath, profile: target, file },
284
+ });
285
+ return;
286
+ }
287
+ const file = path.join(root, LINEAR_PROFILE_REPO_FILE);
288
+ await withFileLock(file, async () => {
289
+ await assertNotSymbolicLink(file);
290
+ const next = applyRepoLocalPin(await readFileOrNull(file), target);
291
+ await atomicWrite(file, next, 0o644);
292
+ });
293
+ outputSuccess({ data: { scope: "repo", profile: target, file } });
294
+ }
295
+ /**
296
+ * `el-linear profile unpin` — remove the repo-local pin, or the exact global
297
+ * owner/repo mapping with `--global`. Preserves sibling keys and wildcard
298
+ * mappings; deletes a config file only when the removed pin was its sole data.
299
+ */
300
+ export async function runProfileUnpin(opts = {}) {
301
+ const ops = defaultRepoProfileOps();
302
+ const cwd = process.cwd();
303
+ const root = ops.repoRoot(cwd);
304
+ if (!root) {
305
+ throw new Error("Not in a git repository. `profile unpin` operates on the repo-local .el-git.json.");
306
+ }
307
+ if (opts.global) {
308
+ const fullpath = ops.originFullpath(cwd);
309
+ if (!fullpath) {
310
+ throw new Error("Cannot determine the origin owner/repo for a global unpin. Add an `origin` remote, or drop --global to remove the repo-local .el-git.json pin.");
311
+ }
312
+ const file = globalPinTablePath();
313
+ await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
314
+ await withFileLock(file, async () => {
315
+ const existing = await readFileOrNull(file);
316
+ const next = removeGlobalPin(existing, fullpath);
317
+ if (next === null) {
318
+ if (existing !== null)
319
+ await fsp.rm(file, { force: true });
320
+ }
321
+ else if (next !== existing) {
322
+ await atomicWrite(file, next, 0o644);
323
+ }
324
+ });
325
+ outputSuccess({
326
+ data: { scope: "global", key: fullpath, unpinned: true, file },
327
+ });
328
+ return;
329
+ }
330
+ const file = path.join(root, LINEAR_PROFILE_REPO_FILE);
331
+ await withFileLock(file, async () => {
332
+ await assertNotSymbolicLink(file);
333
+ const existing = await readFileOrNull(file);
334
+ const next = removeRepoLocalPin(existing);
335
+ if (next === null) {
336
+ if (existing !== null)
337
+ await fsp.rm(file, { force: true });
338
+ }
339
+ else if (next !== existing) {
340
+ await atomicWrite(file, next, 0o644);
341
+ }
342
+ });
343
+ outputSuccess({ data: { scope: "repo", unpinned: true, file } });
344
+ }
197
345
  // ---- Helpers ------------------------------------------------------------
198
346
  async function pathExists(p) {
199
347
  try {
@@ -280,6 +280,11 @@ function flattenProjectUpdate(projectUpdate) {
280
280
  name: updatedProject.name,
281
281
  description: updatedProject.description ?? undefined,
282
282
  content: updatedProject.content ?? undefined,
283
+ // `?.` guards test fixtures/older callers whose mocked response omits
284
+ // the field — real API responses always carry it.
285
+ status: updatedProject.status?.name,
286
+ progress: updatedProject.progress,
287
+ url: updatedProject.url,
283
288
  teams: updatedProject.teams.nodes.map((t) => ({
284
289
  id: t.id,
285
290
  key: t.key,
@@ -515,13 +520,25 @@ async function handleUpdateProject(projectNameOrId, options, command) {
515
520
  if (content !== undefined) {
516
521
  input.content = content;
517
522
  }
518
- if (Object.keys(input).length === 0) {
519
- throw new Error("Nothing to update. Pass at least one of --name, --description, --content, or --content-file.");
523
+ // --status resolves through a name lookup (network), unlike the other
524
+ // flags above — checked here only for the "nothing to update" guard so a
525
+ // bare `--status` still counts as work to do before we've made any calls.
526
+ const hasStatus = hasOption(options, "status");
527
+ if (Object.keys(input).length === 0 && !hasStatus) {
528
+ throw new Error("Nothing to update. Pass at least one of --name, --description, --content, --content-file, or --status.");
520
529
  }
521
530
  const rootOpts = getRootOpts(command);
522
531
  const graphQLService = await createGraphQLService(rootOpts);
523
532
  const linearService = await createLinearService(rootOpts);
524
533
  const projectId = await linearService.resolveProjectId(projectNameOrId);
534
+ if (hasStatus) {
535
+ // DEV-7021: resolves the workspace's configured project statuses by
536
+ // name (case-insensitive) and throws a "Project status ... not found"
537
+ // error listing the valid names on a miss — see
538
+ // `LinearService.resolveProjectStatusId` for why this can't be a
539
+ // static enum the way `VALID_PROJECT_STATES` is.
540
+ input.statusId = await linearService.resolveProjectStatusId(options.status);
541
+ }
525
542
  const updateResult = await graphQLService.rawRequest(UPDATE_PROJECT_FIELDS_MUTATION, { id: projectId, input });
526
543
  const projectUpdate = updateResult.projectUpdate;
527
544
  if (!projectUpdate.success) {
@@ -558,11 +575,13 @@ export function setupProjectsCommands(program) {
558
575
  .action(handleAsyncCommand(handleReadProject));
559
576
  projects
560
577
  .command("update <project>")
561
- .description("Update project name, short description, or markdown content")
578
+ .description("Update project name, short description, markdown content, or status")
562
579
  .option("--name <name>", "project name")
563
580
  .option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
564
581
  .option("--content <markdown>", "full markdown body (shown in project panel)")
565
582
  .option("--content-file <path>", "read the markdown body from a file (or '-' for stdin); mutually exclusive with --content")
583
+ .option("--status <name>", "project status name (case-insensitive; workspace-configured, e.g. Backlog, Planned, In Progress, Completed, Canceled) — lists valid statuses on a miss")
584
+ .option("-q, --quiet", "print one confirmation line (name, status, url) instead of the full JSON")
566
585
  .action(handleAsyncCommand(handleUpdateProject));
567
586
  projects
568
587
  .command("list")
@@ -10,6 +10,14 @@
10
10
  * OPT-IN by design. el-linear is open source, so the gate is dormant unless a
11
11
  * workspace config sets `validation.intakeDecisionGate` to `"warn"` or
12
12
  * `"block"`.
13
+ *
14
+ * DEV-7074: the parser accepts the markdown an author actually types — any
15
+ * list marker (`-`, `*`, `+`, `1.`) and bolded or italicized labels with the
16
+ * colon inside (`**Needed:**`) or outside (`**Needed**:`) the emphasis run.
17
+ * When a field genuinely fails, the diagnostic names WHICH failure it is:
18
+ * an unreadable line, an absent field, an empty value, a placeholder, or a
19
+ * present-but-unjudged value. Reporting a formatting mismatch as empty content
20
+ * sent authors to rewrite prose that was already correct.
13
21
  */
14
22
  export declare const DEFAULT_INTAKE_SECTION_HEADERS: string[];
15
23
  export type IntakeDecisionGateMode = "off" | "warn" | "block";
@@ -18,6 +26,13 @@ export interface IntakeDecisionGateConfig {
18
26
  headers: string[];
19
27
  }
20
28
  export declare function getIntakeDecisionGateConfig(): IntakeDecisionGateConfig;
29
+ /**
30
+ * What is actually wrong with a field whose label parsed. Keeping these apart
31
+ * matters: "the label didn't parse" and "the value is weak" send the author to
32
+ * completely different fixes, and reporting the first as the second sends them
33
+ * to rewrite prose that was already correct (DEV-7074).
34
+ */
35
+ export type IntakeFieldProblem = "empty" | "placeholder" | "non-specific" | "no-judgment" | "placeholder-reason";
21
36
  export type IntakeDecisionEvaluation = {
22
37
  ok: true;
23
38
  header: string;
@@ -28,6 +43,11 @@ export type IntakeDecisionEvaluation = {
28
43
  ok: false;
29
44
  reason: "missing-field";
30
45
  field: string;
46
+ } | {
47
+ ok: false;
48
+ reason: "unparsed-field";
49
+ field: string;
50
+ line: string;
31
51
  } | {
32
52
  ok: false;
33
53
  reason: "duplicate-field";
@@ -40,6 +60,8 @@ export type IntakeDecisionEvaluation = {
40
60
  ok: false;
41
61
  reason: "invalid-field";
42
62
  field: string;
63
+ problem: IntakeFieldProblem;
64
+ value: string;
43
65
  } | {
44
66
  ok: false;
45
67
  reason: "not-proceeding";