@enrichlayer/el-linear 1.37.2 → 1.38.0
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 +3 -2
- package/claude-skills/linear-operations/SKILL.md +31 -30
- package/dist/commands/comments.js +2 -1
- package/dist/commands/issues/description.js +2 -1
- package/dist/commands/issues.js +18 -1
- package/dist/commands/labels.js +2 -1
- package/dist/commands/projects.js +53 -1
- package/dist/queries/projects-types.d.ts +10 -0
- package/dist/queries/projects.d.ts +1 -0
- package/dist/queries/projects.js +20 -0
- package/dist/utils/gate-telemetry.d.ts +20 -0
- package/dist/utils/gate-telemetry.js +26 -1
- package/dist/utils/inline-text-input.d.ts +7 -0
- package/dist/utils/inline-text-input.js +12 -0
- package/dist/utils/relation-candidate-prompt.d.ts +12 -5
- package/dist/utils/relation-candidate-prompt.js +23 -9
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -277,8 +277,9 @@ el-linear can record each gate's fire/override decision to a local JSONL file so
|
|
|
277
277
|
you can measure its **override-rate** and tell whether it's too aggressive. It is
|
|
278
278
|
**off by default** and writes nothing unless you opt in (e.g.
|
|
279
279
|
`export EL_TELEMETRY_DIR=<path>`); there is no server or database, and
|
|
280
|
-
`EL_TELEMETRY_DISABLED=1` forces it off.
|
|
281
|
-
|
|
280
|
+
`EL_TELEMETRY_DISABLED=1` forces it off. The active ledger rotates to one `.old`
|
|
281
|
+
backup before append when it exceeds 2 MiB. Full opt-in rules, the event schema,
|
|
282
|
+
and a `jq` reader are in [docs/telemetry.md](./docs/telemetry.md).
|
|
282
283
|
|
|
283
284
|
### Networking (IPv4 preference)
|
|
284
285
|
|
|
@@ -237,6 +237,16 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
|
|
|
237
237
|
el-linear issues create "Title" --team ENG --related-to "ENG-456,ENG-789" ... 2>&1
|
|
238
238
|
```
|
|
239
239
|
|
|
240
|
+
### Cross-linking: be generous, link proactively, don't wait ([DEV-5853](https://linear.app/verticalint/issue/DEV-5853/))
|
|
241
|
+
|
|
242
|
+
**The default is to link, not to ask.** A cross-link is cheap and reversible; a *missing* link is invisible and costs reviewers and SOP tooling the context they need. When you identify an issue that is **related** / a **blocker** / a **follow-up** / the **origin** of the work in front of you, create the relation yourself — don't stage the candidates and wait for the human to name them, and don't hedge with "tell me if you want these linked."
|
|
243
|
+
|
|
244
|
+
- **At create time**, pass `--related-to "<ids>"` / `--parent <id>` / `--blocked-by <id>` directly on `issues create` — this path typically passes the classifier, so link generously the moment you file.
|
|
245
|
+
- **After the fact**, call `el-linear issues relate <source> --related-to "<ids>"` (or `--blocked-by` / `--blocks` / `--duplicate-of`) proactively, the same way.
|
|
246
|
+
- Over-linking is self-correcting (a wrong relation is one command to remove); under-linking is not. When in doubt, link.
|
|
247
|
+
|
|
248
|
+
The one exception is below: if the auto-mode permission classifier actually blocks a specific post-hoc `relate` call, that block — not caution — is your signal to surface the candidates for a one-word confirm.
|
|
249
|
+
|
|
240
250
|
### Existence check — before an "add capability X" issue ([DEV-5097](https://linear.app/verticalint/issue/DEV-5097/))
|
|
241
251
|
|
|
242
252
|
The dup-check above guards against duplicating an *issue*. This guards against duplicating *reality*: before filing an issue to **add** a flag / guard / command / subcommand, confirm it doesn't **already exist**.
|
|
@@ -248,38 +258,29 @@ Skipping it cost real rework across sessions: a hook guard and `--jq`/`--fields`
|
|
|
248
258
|
|
|
249
259
|
When `el-linear issues search` (or the cross-resource `search`) returns rows
|
|
250
260
|
carrying issue identifiers, the JSON envelope embeds a `_warnings` line
|
|
251
|
-
starting with `relation_candidates:`
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
- **Calling `issues relate` directly off your own search output** — even if
|
|
273
|
-
the IDs are real and the candidates look obvious, this is the exact path
|
|
274
|
-
the auto-mode guard refuses.
|
|
275
|
-
- **Splitting one relate call into N single-ID calls** to "look smaller" —
|
|
276
|
-
same provenance problem, same block, just multiplied.
|
|
277
|
-
- **Asking the user a yes/no question** ("Should I link these?") instead of
|
|
278
|
-
having them name the IDs — yes answers stay agent-inferred, the reply
|
|
279
|
-
must carry the IDs to convert them to user-specified.
|
|
261
|
+
starting with `relation_candidates:` enumerating the candidate IDs. Treat it
|
|
262
|
+
as a **convenience list of link candidates, not a stop sign** — under the
|
|
263
|
+
proactive default above, relate the ones that are genuinely related/blocking
|
|
264
|
+
without waiting to be told. Prefer create-time `--related-to` when the search
|
|
265
|
+
ran as part of filing a new issue (create-time relations typically pass the
|
|
266
|
+
classifier); otherwise call `issues relate` directly.
|
|
267
|
+
|
|
268
|
+
**Residual auto-mode constraint (the only reason to pause).** Claude Code's
|
|
269
|
+
auto-mode permission classifier *may* block a standalone `issues relate
|
|
270
|
+
--related-to "<ids>"` when it judges the IDs *agent-inferred* (surfaced by
|
|
271
|
+
your own search) rather than *user-specified*, because each peer is a write
|
|
272
|
+
target. A standing user instruction to cross-link generously is itself
|
|
273
|
+
authorization — so proceed by default. But if a specific `relate` call is
|
|
274
|
+
**actually blocked** by the classifier, that block is your cue: surface the
|
|
275
|
+
candidate IDs to the user for a one-word confirm, then re-run with the
|
|
276
|
+
user-named IDs (which pass). This is the **exception path**, not the default —
|
|
277
|
+
do not pre-emptively withhold links the classifier would have allowed. Two
|
|
278
|
+
ways to keep it frictionless: file relations at create time (`--related-to`),
|
|
279
|
+
or the operator adds a permission rule / runs non-auto-mode so post-hoc
|
|
280
|
+
`relate` never trips.
|
|
280
281
|
|
|
281
282
|
If `--include-closed` search returns no matches, no `relation_candidates:`
|
|
282
|
-
warning is emitted
|
|
283
|
+
warning is emitted and the flow proceeds normally.
|
|
283
284
|
|
|
284
285
|
### Viewing existing relations
|
|
285
286
|
|
|
@@ -4,6 +4,7 @@ import { CREATE_COMMENT_MUTATION, DELETE_COMMENT_MUTATION, GET_COMMENT_QUERY, LI
|
|
|
4
4
|
import { autoLinkReferences, } from "../utils/auto-link-references.js";
|
|
5
5
|
import { applyFooter } from "../utils/footer.js";
|
|
6
6
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
7
|
+
import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
|
|
7
8
|
import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
|
|
8
9
|
import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
|
|
9
10
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
@@ -79,7 +80,7 @@ function readBody(options) {
|
|
|
79
80
|
return readFileSync(options.bodyFile, "utf-8");
|
|
80
81
|
}
|
|
81
82
|
if (options.body) {
|
|
82
|
-
return options.body;
|
|
83
|
+
return normalizeInlineTextInput(options.body);
|
|
83
84
|
}
|
|
84
85
|
throw new Error("Either --body or --body-file is required");
|
|
85
86
|
}
|
|
@@ -20,6 +20,7 @@ import fs from "node:fs";
|
|
|
20
20
|
import { loadConfig } from "../../config/config.js";
|
|
21
21
|
import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
|
|
22
22
|
import { autoLinkReferences, } from "../../utils/auto-link-references.js";
|
|
23
|
+
import { normalizeInlineTextInput } from "../../utils/inline-text-input.js";
|
|
23
24
|
import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
|
|
24
25
|
import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
|
|
25
26
|
import { validateReferences } from "../../utils/validate-references.js";
|
|
@@ -59,7 +60,7 @@ export function resolveDescription(options) {
|
|
|
59
60
|
return readDescriptionFile(options.descriptionFile);
|
|
60
61
|
}
|
|
61
62
|
if (hasInline) {
|
|
62
|
-
return options.description;
|
|
63
|
+
return normalizeInlineTextInput(options.description);
|
|
63
64
|
}
|
|
64
65
|
if (hasTemplate) {
|
|
65
66
|
const templates = loadConfig().descriptionTemplates ?? {};
|
package/dist/commands/issues.js
CHANGED
|
@@ -13,6 +13,7 @@ import { applyFooter } from "../utils/footer.js";
|
|
|
13
13
|
import { emitGateEvent } from "../utils/gate-telemetry.js";
|
|
14
14
|
import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
|
|
15
15
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
16
|
+
import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
|
|
16
17
|
import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
17
18
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
18
19
|
import { logger } from "../utils/logger.js";
|
|
@@ -205,7 +206,8 @@ async function handleListIssues(options, command) {
|
|
|
205
206
|
// user passes --include-closed OR explicit --status. Explicit status
|
|
206
207
|
// wins because the user already named the workflow states they want.
|
|
207
208
|
const excludeTerminalStates = !options.includeClosed && explicitStatus === undefined;
|
|
208
|
-
const hasOtherFilters = options.
|
|
209
|
+
const hasOtherFilters = options.search ||
|
|
210
|
+
options.team ||
|
|
209
211
|
options.labels ||
|
|
210
212
|
options.status ||
|
|
211
213
|
options.assignee ||
|
|
@@ -223,6 +225,7 @@ async function handleListIssues(options, command) {
|
|
|
223
225
|
// (DEV-4478 cycle-1.)
|
|
224
226
|
if (hasOtherFilters || excludeTerminalStates || options.includeClosed) {
|
|
225
227
|
const searchArgs = {
|
|
228
|
+
query: options.search,
|
|
226
229
|
teamId: options.team ? resolveTeam(options.team) : undefined,
|
|
227
230
|
assigneeId: options.assignee
|
|
228
231
|
? await resolveAssignee(options.assignee, rootOpts)
|
|
@@ -244,8 +247,15 @@ async function handleListIssues(options, command) {
|
|
|
244
247
|
if (excludeTerminalStates) {
|
|
245
248
|
outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
|
|
246
249
|
}
|
|
250
|
+
if (options.search) {
|
|
251
|
+
const relationPrompt = buildRelationCandidatePrompt(result);
|
|
252
|
+
if (relationPrompt) {
|
|
253
|
+
outputWarning(relationPrompt);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
247
256
|
warnIfTruncated(result.length, limit);
|
|
248
257
|
outputIssues(result, command, {
|
|
258
|
+
query: options.search,
|
|
249
259
|
team: options.team,
|
|
250
260
|
});
|
|
251
261
|
}
|
|
@@ -947,6 +957,12 @@ async function handleUpdateIssue(issueId, options, command) {
|
|
|
947
957
|
if (options.descriptionFile) {
|
|
948
958
|
options.description = readDescriptionFile(options.descriptionFile);
|
|
949
959
|
}
|
|
960
|
+
else if (typeof options.description === "string") {
|
|
961
|
+
options.description = normalizeInlineTextInput(options.description);
|
|
962
|
+
}
|
|
963
|
+
if (typeof options.appendDescription === "string") {
|
|
964
|
+
options.appendDescription = normalizeInlineTextInput(options.appendDescription);
|
|
965
|
+
}
|
|
950
966
|
validateUpdateOptions(options);
|
|
951
967
|
const rootOpts = getRootOpts(command);
|
|
952
968
|
const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
|
|
@@ -1228,6 +1244,7 @@ export function setupIssuesCommands(program) {
|
|
|
1228
1244
|
.command("list")
|
|
1229
1245
|
.description("List issues.")
|
|
1230
1246
|
.option("-l, --limit <number>", "limit results", "25")
|
|
1247
|
+
.option("--search <query>", "full-text search term; composes with list filters")
|
|
1231
1248
|
.option("--team <team>", "filter by team key (EL: resolves names)")
|
|
1232
1249
|
.option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
|
|
1233
1250
|
.option("--delegate <delegate>", "filter by delegated agent (name, alias, or ID)")
|
package/dist/commands/labels.js
CHANGED
|
@@ -9,8 +9,9 @@ import { getRootOpts } from "../utils/root-opts.js";
|
|
|
9
9
|
import { parsePositiveInt, validateHexColor } from "../utils/validators.js";
|
|
10
10
|
async function handleCreateLabel(name, options, command) {
|
|
11
11
|
const rootOpts = getRootOpts(command);
|
|
12
|
-
const teamId = resolveTeam(options.team);
|
|
13
12
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
13
|
+
const linearService = await createLinearService(rootOpts);
|
|
14
|
+
const teamId = await linearService.resolveTeamId(resolveTeam(options.team));
|
|
14
15
|
const input = { name, teamId };
|
|
15
16
|
if (options.color) {
|
|
16
17
|
input.color = validateHexColor(options.color);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadConfig } from "../config/config.js";
|
|
2
2
|
import { resolveTeam } from "../config/resolver.js";
|
|
3
|
-
import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, PROJECT_READ_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
|
|
3
|
+
import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, PROJECT_READ_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_FIELDS_MUTATION, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
|
|
4
4
|
import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
|
|
5
5
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
6
6
|
import { createLinearService } from "../utils/linear-service.js";
|
|
@@ -229,6 +229,26 @@ function formatTeamsOutput(projectUpdate) {
|
|
|
229
229
|
})),
|
|
230
230
|
};
|
|
231
231
|
}
|
|
232
|
+
function hasOption(options, key) {
|
|
233
|
+
return options[key] !== undefined;
|
|
234
|
+
}
|
|
235
|
+
function flattenProjectUpdate(projectUpdate) {
|
|
236
|
+
if (!projectUpdate.project) {
|
|
237
|
+
throw new Error("Failed to update project");
|
|
238
|
+
}
|
|
239
|
+
const updatedProject = projectUpdate.project;
|
|
240
|
+
return {
|
|
241
|
+
id: updatedProject.id,
|
|
242
|
+
name: updatedProject.name,
|
|
243
|
+
description: updatedProject.description ?? undefined,
|
|
244
|
+
content: updatedProject.content ?? undefined,
|
|
245
|
+
teams: updatedProject.teams.nodes.map((t) => ({
|
|
246
|
+
id: t.id,
|
|
247
|
+
key: t.key,
|
|
248
|
+
name: t.name,
|
|
249
|
+
})),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
232
252
|
async function handleAddTeam(projectNameOrId, teamInput, _options, command) {
|
|
233
253
|
const rootOpts = getRootOpts(command);
|
|
234
254
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
@@ -437,6 +457,31 @@ async function handleReadProject(projectNameOrId, _options, command) {
|
|
|
437
457
|
teams: teams.nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
|
|
438
458
|
});
|
|
439
459
|
}
|
|
460
|
+
async function handleUpdateProject(projectNameOrId, options, command) {
|
|
461
|
+
const input = {};
|
|
462
|
+
if (hasOption(options, "name")) {
|
|
463
|
+
input.name = options.name;
|
|
464
|
+
}
|
|
465
|
+
if (hasOption(options, "description")) {
|
|
466
|
+
input.description = options.description;
|
|
467
|
+
}
|
|
468
|
+
if (hasOption(options, "content")) {
|
|
469
|
+
input.content = options.content;
|
|
470
|
+
}
|
|
471
|
+
if (Object.keys(input).length === 0) {
|
|
472
|
+
throw new Error("Nothing to update. Pass at least one of --name, --description, or --content.");
|
|
473
|
+
}
|
|
474
|
+
const rootOpts = getRootOpts(command);
|
|
475
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
476
|
+
const linearService = await createLinearService(rootOpts);
|
|
477
|
+
const projectId = await linearService.resolveProjectId(projectNameOrId);
|
|
478
|
+
const updateResult = await graphQLService.rawRequest(UPDATE_PROJECT_FIELDS_MUTATION, { id: projectId, input });
|
|
479
|
+
const projectUpdate = updateResult.projectUpdate;
|
|
480
|
+
if (!projectUpdate.success) {
|
|
481
|
+
throw new Error(`Failed to update project "${projectNameOrId}"`);
|
|
482
|
+
}
|
|
483
|
+
outputSuccess(flattenProjectUpdate(projectUpdate));
|
|
484
|
+
}
|
|
440
485
|
export function setupProjectsCommands(program) {
|
|
441
486
|
const projects = program
|
|
442
487
|
.command("projects")
|
|
@@ -463,6 +508,13 @@ export function setupProjectsCommands(program) {
|
|
|
463
508
|
.command("read <project>")
|
|
464
509
|
.description("Read one project's full details (resolves name/slug/URL/ID). `--format summary` shows state, lead, teams, target, progress, url; JSON includes description/content.")
|
|
465
510
|
.action(handleAsyncCommand(handleReadProject));
|
|
511
|
+
projects
|
|
512
|
+
.command("update <project>")
|
|
513
|
+
.description("Update project name, short description, or markdown content")
|
|
514
|
+
.option("--name <name>", "project name")
|
|
515
|
+
.option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
|
|
516
|
+
.option("--content <markdown>", "full markdown body (shown in project panel)")
|
|
517
|
+
.action(handleAsyncCommand(handleUpdateProject));
|
|
466
518
|
projects
|
|
467
519
|
.command("list")
|
|
468
520
|
.description("List projects")
|
|
@@ -77,6 +77,16 @@ export interface UpdateProjectResponse {
|
|
|
77
77
|
project: ProjectBaseNode | null;
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
interface ProjectUpdateFieldsNode extends ProjectBaseNode {
|
|
81
|
+
description: string | null;
|
|
82
|
+
content: string | null;
|
|
83
|
+
}
|
|
84
|
+
export interface UpdateProjectFieldsResponse {
|
|
85
|
+
projectUpdate: {
|
|
86
|
+
success: boolean;
|
|
87
|
+
project: ProjectUpdateFieldsNode | null;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
80
90
|
interface ProjectArchiveEntity {
|
|
81
91
|
id: string;
|
|
82
92
|
}
|
|
@@ -14,5 +14,6 @@ export declare const GET_PROJECT_TEAM_ISSUES_QUERY = "\n query GetProjectTeamIs
|
|
|
14
14
|
export declare const SEARCH_PROJECTS_BY_NAME_QUERY = "\n query SearchProjectsByName($name: String!) {\n projects(filter: { name: { containsIgnoreCase: $name } }, first: 10) {\n nodes {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
|
|
15
15
|
export declare const CREATE_PROJECT_MUTATION = "\n mutation CreateProject($input: ProjectCreateInput!) {\n projectCreate(input: $input) {\n success\n project {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
|
|
16
16
|
export declare const UPDATE_PROJECT_MUTATION = "\n mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
|
|
17
|
+
export declare const UPDATE_PROJECT_FIELDS_MUTATION = "\n mutation UpdateProjectFields($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n description\n content\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
|
|
17
18
|
export declare const ARCHIVE_PROJECT_MUTATION = "\n mutation ArchiveProject($id: String!) {\n projectArchive(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
|
|
18
19
|
export declare const DELETE_PROJECT_MUTATION = "\n mutation DeleteProject($id: String!) {\n projectDelete(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
|
package/dist/queries/projects.js
CHANGED
|
@@ -129,6 +129,26 @@ export const UPDATE_PROJECT_MUTATION = `
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
`;
|
|
132
|
+
export const UPDATE_PROJECT_FIELDS_MUTATION = `
|
|
133
|
+
mutation UpdateProjectFields($id: String!, $input: ProjectUpdateInput!) {
|
|
134
|
+
projectUpdate(id: $id, input: $input) {
|
|
135
|
+
success
|
|
136
|
+
project {
|
|
137
|
+
id
|
|
138
|
+
name
|
|
139
|
+
description
|
|
140
|
+
content
|
|
141
|
+
teams {
|
|
142
|
+
nodes {
|
|
143
|
+
id
|
|
144
|
+
key
|
|
145
|
+
name
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
`;
|
|
132
152
|
export const ARCHIVE_PROJECT_MUTATION = `
|
|
133
153
|
mutation ArchiveProject($id: String!) {
|
|
134
154
|
projectArchive(id: $id) {
|
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic-gate fire/override telemetry (DEV-4834, sub of DEV-4831).
|
|
3
|
+
*
|
|
4
|
+
* The `issues create` duplicate-detection gate (DEV-4823) records each decision
|
|
5
|
+
* it makes as a `gate` event so a reader (the Enrich Layer `el-telemetry gates`
|
|
6
|
+
* command, or any JSONL consumer) can compute the gate's override-rate
|
|
7
|
+
* (overridden / total) and tell whether the threshold is noisy.
|
|
8
|
+
*
|
|
9
|
+
* **Opt-in.** el-linear is open-source; most installs have no telemetry, and we
|
|
10
|
+
* must never write files a user didn't ask for. Emission is therefore OFF by
|
|
11
|
+
* default and turns on only when telemetry is actually configured — see
|
|
12
|
+
* {@link decideGateLedger}. The ledger is a plain local JSONL file
|
|
13
|
+
* (`gate-events.jsonl`); there is no server or database. el-linear can't import
|
|
14
|
+
* `el-telemetry` (separate package), so it writes by **path-contract** — the
|
|
15
|
+
* same approach `el-hook` uses. The path mirrors `el-telemetry`'s
|
|
16
|
+
* `GATE_EVENTS_PATH`; keep the two in sync. Format + reader are documented in
|
|
17
|
+
* `docs/telemetry.md`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const GATE_LEDGER_MAX_BYTES: number;
|
|
20
|
+
export declare const GATE_LEDGER_BACKUP_SUFFIX = ".old";
|
|
1
21
|
/** Resolve where the ledger lives — for a *reader* locating the file (mirrors
|
|
2
22
|
* el-telemetry's `GATE_EVENTS_PATH`). This is NOT the emit decision: it ignores
|
|
3
23
|
* the opt-in policy, so never write through it — `emitGateEvent` goes through
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { appendFile, mkdir, rename, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
/**
|
|
@@ -20,6 +20,8 @@ import { dirname, join } from "node:path";
|
|
|
20
20
|
* `GATE_EVENTS_PATH`; keep the two in sync. Format + reader are documented in
|
|
21
21
|
* `docs/telemetry.md`.
|
|
22
22
|
*/
|
|
23
|
+
export const GATE_LEDGER_MAX_BYTES = 2 * 1024 * 1024;
|
|
24
|
+
export const GATE_LEDGER_BACKUP_SUFFIX = ".old";
|
|
23
25
|
/** The default ledger directory when `EL_TELEMETRY_DIR` is not set. */
|
|
24
26
|
function defaultTelemetryDir() {
|
|
25
27
|
return join(homedir(), ".cache", "el-telemetry");
|
|
@@ -67,6 +69,28 @@ function gateLedgerIfEnabled() {
|
|
|
67
69
|
defaultDirExists: existsSync(defaultDir),
|
|
68
70
|
});
|
|
69
71
|
}
|
|
72
|
+
async function rotateGateLedgerIfOverLimit(path) {
|
|
73
|
+
let size = 0;
|
|
74
|
+
try {
|
|
75
|
+
const s = await stat(path);
|
|
76
|
+
if (!s.isFile())
|
|
77
|
+
return;
|
|
78
|
+
size = s.size;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (size <= GATE_LEDGER_MAX_BYTES)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
const backupPath = `${path}${GATE_LEDGER_BACKUP_SUFFIX}`;
|
|
87
|
+
await rm(backupPath, { force: true });
|
|
88
|
+
await rename(path, backupPath);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// best-effort — telemetry never blocks issue creation
|
|
92
|
+
}
|
|
93
|
+
}
|
|
70
94
|
/**
|
|
71
95
|
* Best-effort append of a gate event to the local ledger — but only when
|
|
72
96
|
* telemetry is opted in ({@link gateLedgerIfEnabled}); otherwise a silent
|
|
@@ -80,6 +104,7 @@ export async function emitGateEvent(name, subcommand, event) {
|
|
|
80
104
|
}
|
|
81
105
|
try {
|
|
82
106
|
await mkdir(dirname(path), { recursive: true });
|
|
107
|
+
await rotateGateLedgerIfOverLimit(path);
|
|
83
108
|
const record = {
|
|
84
109
|
ts: new Date().toISOString(),
|
|
85
110
|
kind: "gate",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize shell-literal newline escapes in inline CLI text fields.
|
|
3
|
+
*
|
|
4
|
+
* File inputs are intentionally excluded by call site: a file body is already
|
|
5
|
+
* explicit authored text and may intentionally contain backslash sequences.
|
|
6
|
+
*/
|
|
7
|
+
export declare function normalizeInlineTextInput(value: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize shell-literal newline escapes in inline CLI text fields.
|
|
3
|
+
*
|
|
4
|
+
* File inputs are intentionally excluded by call site: a file body is already
|
|
5
|
+
* explicit authored text and may intentionally contain backslash sequences.
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeInlineTextInput(value) {
|
|
8
|
+
return value
|
|
9
|
+
.replace(/\\r\\n/g, "\n")
|
|
10
|
+
.replace(/\\n/g, "\n")
|
|
11
|
+
.replace(/\\r/g, "\n");
|
|
12
|
+
}
|
|
@@ -41,16 +41,23 @@
|
|
|
41
41
|
*/
|
|
42
42
|
export declare function extractCandidateIdentifiers(rows: unknown[]): string[];
|
|
43
43
|
/**
|
|
44
|
-
* Build the relation-candidate
|
|
45
|
-
*
|
|
44
|
+
* Build the relation-candidate warning string, or `null` when the result set
|
|
45
|
+
* has no identifier-bearing rows (nothing to surface).
|
|
46
46
|
*
|
|
47
47
|
* Shape (single line, structured-prose so a skill can match on the prefix):
|
|
48
48
|
*
|
|
49
49
|
* relation_candidates: Found N candidate related issues (DEV-1, DEV-2, …).
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* Link the relevant ones now — at create time with --related-to, or
|
|
51
|
+
* `issues relate <id> --related-to "<ids>"`. If auto-mode blocks an
|
|
52
|
+
* agent-inferred relate, reply with the IDs you want linked
|
|
53
|
+
* (e.g. "link DEV-1 and DEV-2"), or "no links" to skip.
|
|
52
54
|
*
|
|
53
|
-
*
|
|
55
|
+
* DEV-5853: the primary framing is **proactive** — this is a convenience list
|
|
56
|
+
* of link candidates, not a stop sign. Relate the relevant ones directly
|
|
57
|
+
* rather than waiting to be told; the reply flow is the *fallback* for when
|
|
58
|
+
* the auto-mode classifier actually blocks an agent-inferred `issues relate`
|
|
59
|
+
* (create-time `--related-to` typically passes, so prefer it). The
|
|
60
|
+
* `relation_candidates:` prefix matches the existing `results_truncated:`
|
|
54
61
|
* convention in `outputWarning` callers — a stable token a skill / agent
|
|
55
62
|
* harness can grep for without parsing free-form prose.
|
|
56
63
|
*/
|
|
@@ -59,16 +59,23 @@ export function extractCandidateIdentifiers(rows) {
|
|
|
59
59
|
return out;
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
|
-
* Build the relation-candidate
|
|
63
|
-
*
|
|
62
|
+
* Build the relation-candidate warning string, or `null` when the result set
|
|
63
|
+
* has no identifier-bearing rows (nothing to surface).
|
|
64
64
|
*
|
|
65
65
|
* Shape (single line, structured-prose so a skill can match on the prefix):
|
|
66
66
|
*
|
|
67
67
|
* relation_candidates: Found N candidate related issues (DEV-1, DEV-2, …).
|
|
68
|
-
*
|
|
69
|
-
*
|
|
68
|
+
* Link the relevant ones now — at create time with --related-to, or
|
|
69
|
+
* `issues relate <id> --related-to "<ids>"`. If auto-mode blocks an
|
|
70
|
+
* agent-inferred relate, reply with the IDs you want linked
|
|
71
|
+
* (e.g. "link DEV-1 and DEV-2"), or "no links" to skip.
|
|
70
72
|
*
|
|
71
|
-
*
|
|
73
|
+
* DEV-5853: the primary framing is **proactive** — this is a convenience list
|
|
74
|
+
* of link candidates, not a stop sign. Relate the relevant ones directly
|
|
75
|
+
* rather than waiting to be told; the reply flow is the *fallback* for when
|
|
76
|
+
* the auto-mode classifier actually blocks an agent-inferred `issues relate`
|
|
77
|
+
* (create-time `--related-to` typically passes, so prefer it). The
|
|
78
|
+
* `relation_candidates:` prefix matches the existing `results_truncated:`
|
|
72
79
|
* convention in `outputWarning` callers — a stable token a skill / agent
|
|
73
80
|
* harness can grep for without parsing free-form prose.
|
|
74
81
|
*/
|
|
@@ -82,13 +89,20 @@ export function buildRelationCandidatePrompt(rows) {
|
|
|
82
89
|
? `${shown.join(", ")}, … (+${overflow} more)`
|
|
83
90
|
: shown.join(", ");
|
|
84
91
|
// Build two concrete example IDs from the head of the list so the
|
|
85
|
-
// "reply with the IDs you want linked" example is realistic for
|
|
86
|
-
// caller's actual search rather than a fixed placeholder. Single-result
|
|
92
|
+
// fallback "reply with the IDs you want linked" example is realistic for
|
|
93
|
+
// the caller's actual search rather than a fixed placeholder. Single-result
|
|
87
94
|
// case still reads naturally ("link DEV-1").
|
|
88
95
|
const example = shown.length >= 2 ? `link ${shown[0]} and ${shown[1]}` : `link ${shown[0]}`;
|
|
89
96
|
const noun = ids.length === 1 ? "candidate related issue" : "candidate related issues";
|
|
97
|
+
// DEV-5853: proactive framing first (relate the relevant ones directly),
|
|
98
|
+
// then the reply-flow fallback for when auto-mode blocks an agent-inferred
|
|
99
|
+
// relate. The `relate the relevant ones` / `reply with the IDs you want
|
|
100
|
+
// linked` / `"no links"` / DEV-4494 tokens are all preserved so downstream
|
|
101
|
+
// skill/agent matchers stay stable.
|
|
90
102
|
return (`relation_candidates: Found ${ids.length} ${noun} (${idList}). ` +
|
|
91
|
-
`
|
|
92
|
-
|
|
103
|
+
`Link the relevant ones now — at create time with --related-to, or ` +
|
|
104
|
+
`\`issues relate <id> --related-to "<ids>"\`. ` +
|
|
105
|
+
`If auto-mode blocks an agent-inferred relate, reply with the IDs you want linked ` +
|
|
106
|
+
`(e.g. "${example}"), or "no links" to skip. ` +
|
|
93
107
|
`(Agent-inferred IDs are blocked by auto-mode; user-named IDs pass — DEV-4494.)`);
|
|
94
108
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.38.0",
|
|
4
4
|
"description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
|
|
5
5
|
"main": "dist/main.js",
|
|
6
6
|
"types": "dist/main.d.ts",
|
|
@@ -68,8 +68,9 @@
|
|
|
68
68
|
"@sentry/node": "^10.50.0"
|
|
69
69
|
},
|
|
70
70
|
"scripts": {
|
|
71
|
-
"build": "tsc && chmod +x dist/main.js",
|
|
71
|
+
"build": "tsc -p tsconfig.build.json && chmod +x dist/main.js",
|
|
72
72
|
"clean": "rm -rf dist/",
|
|
73
|
+
"typecheck": "tsc --noEmit",
|
|
73
74
|
"lint": "biome check .",
|
|
74
75
|
"lint:fix": "biome check --fix .",
|
|
75
76
|
"start": "tsx src/main.ts",
|